From b8a4c7bea3fe4202db0e19e593202bfb8501b727 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Fri, 3 Mar 2017 12:16:16 +0100 Subject: [PATCH] Add a general string to method call parsing utility class to the Jackson module. It is useful for the shell work. --- .../net/corda/jackson/JacksonSupport.kt | 26 +-- .../corda/jackson/StringToMethodCallParser.kt | 184 ++++++++++++++++++ .../jackson/StringToMethodCallParserTest.kt | 34 ++++ .../net/corda/testing/http/HttpUtils.kt | 4 +- 4 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 client/jackson/src/main/kotlin/net/corda/jackson/StringToMethodCallParser.kt create mode 100644 client/jackson/src/test/kotlin/net/corda/jackson/StringToMethodCallParserTest.kt diff --git a/client/jackson/src/main/kotlin/net/corda/jackson/JacksonSupport.kt b/client/jackson/src/main/kotlin/net/corda/jackson/JacksonSupport.kt index e3d060a1fa..d9cdd9a0ce 100644 --- a/client/jackson/src/main/kotlin/net/corda/jackson/JacksonSupport.kt +++ b/client/jackson/src/main/kotlin/net/corda/jackson/JacksonSupport.kt @@ -1,9 +1,6 @@ package net.corda.jackson -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.core.JsonParseException -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.core.* import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.deser.std.NumberDeserializers import com.fasterxml.jackson.databind.deser.std.StringArrayDeserializer @@ -30,6 +27,7 @@ import java.util.* * Note that Jackson can also be used to serialise/deserialise other formats such as Yaml and XML. */ object JacksonSupport { + // TODO: This API could use some tidying up - there should really only need to be one kind of mapper. // If you change this API please update the docs in the docsite (json.rst) interface PartyObjectMapper { @@ -37,15 +35,15 @@ object JacksonSupport { fun partyFromKey(owningKey: CompositeKey): Party? } - class RpcObjectMapper(val rpc: CordaRPCOps) : PartyObjectMapper, ObjectMapper() { + class RpcObjectMapper(val rpc: CordaRPCOps, factory: JsonFactory) : PartyObjectMapper, ObjectMapper(factory) { override fun partyFromName(partyName: String): Party? = rpc.partyFromName(partyName) override fun partyFromKey(owningKey: CompositeKey): Party? = rpc.partyFromKey(owningKey) } - class IdentityObjectMapper(val identityService: IdentityService) : PartyObjectMapper, ObjectMapper(){ + class IdentityObjectMapper(val identityService: IdentityService, factory: JsonFactory) : PartyObjectMapper, ObjectMapper(factory) { override fun partyFromName(partyName: String): Party? = identityService.partyFromName(partyName) override fun partyFromKey(owningKey: CompositeKey): Party? = identityService.partyFromKey(owningKey) } - class NoPartyObjectMapper: PartyObjectMapper, ObjectMapper() { + class NoPartyObjectMapper(factory: JsonFactory): PartyObjectMapper, ObjectMapper(factory) { override fun partyFromName(partyName: String): Party? = throw UnsupportedOperationException() override fun partyFromKey(owningKey: CompositeKey): Party? = throw UnsupportedOperationException() } @@ -59,7 +57,9 @@ object JacksonSupport { addSerializer(BigDecimal::class.java, ToStringSerializer) addDeserializer(BigDecimal::class.java, NumberDeserializers.BigDecimalDeserializer()) addSerializer(SecureHash::class.java, SecureHashSerializer) + addSerializer(SecureHash.SHA256::class.java, SecureHashSerializer) addDeserializer(SecureHash::class.java, SecureHashDeserializer()) + addDeserializer(SecureHash.SHA256::class.java, SecureHashDeserializer()) addDeserializer(BusinessCalendar::class.java, CalendarDeserializer) // For ed25519 pubkeys @@ -86,16 +86,16 @@ object JacksonSupport { } /** Mapper requiring RPC support to deserialise parties from names */ - @JvmStatic - fun createDefaultMapper(rpc: CordaRPCOps): ObjectMapper = configureMapper(RpcObjectMapper(rpc)) + @JvmStatic @JvmOverloads + fun createDefaultMapper(rpc: CordaRPCOps, factory: JsonFactory = JsonFactory()): ObjectMapper = configureMapper(RpcObjectMapper(rpc, factory)) /** For testing or situations where deserialising parties is not required */ - @JvmStatic - fun createNonRpcMapper(): ObjectMapper = configureMapper(NoPartyObjectMapper()) + @JvmStatic @JvmOverloads + fun createNonRpcMapper(factory: JsonFactory = JsonFactory()): ObjectMapper = configureMapper(NoPartyObjectMapper(factory)) /** For testing with an in memory identity service */ - @JvmStatic - fun createInMemoryMapper(identityService: IdentityService) = configureMapper(IdentityObjectMapper(identityService)) + @JvmStatic @JvmOverloads + fun createInMemoryMapper(identityService: IdentityService, factory: JsonFactory = JsonFactory()) = configureMapper(IdentityObjectMapper(identityService, factory)) private fun configureMapper(mapper: ObjectMapper): ObjectMapper = mapper.apply { enable(SerializationFeature.INDENT_OUTPUT) diff --git a/client/jackson/src/main/kotlin/net/corda/jackson/StringToMethodCallParser.kt b/client/jackson/src/main/kotlin/net/corda/jackson/StringToMethodCallParser.kt new file mode 100644 index 0000000000..15c48a745a --- /dev/null +++ b/client/jackson/src/main/kotlin/net/corda/jackson/StringToMethodCallParser.kt @@ -0,0 +1,184 @@ +package net.corda.jackson + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import net.corda.jackson.StringToMethodCallParser.ParsedMethodCall +import org.slf4j.LoggerFactory +import java.lang.reflect.Constructor +import java.lang.reflect.Method +import java.util.concurrent.Callable +import javax.annotation.concurrent.ThreadSafe +import kotlin.reflect.KClass +import kotlin.reflect.KFunction +import kotlin.reflect.KotlinReflectionInternalError +import kotlin.reflect.jvm.kotlinFunction + +/** + * This class parses strings in a format designed for human usability into [ParsedMethodCall] objects representing a + * ready-to-invoke call on the given target object. The strings accepted by this class are a minor variant of + * [Yaml](http://www.yaml.org/spec/1.2/spec.html) and can be easily typed at a command line. Intended use cases include + * things like the Corda shell, text-based RPC dispatch, simple scripting and so on. + * + * # Syntax + * + * The format of the string is as follows. The first word is the name of the method and must always be present. The rest, + * which is optional, is wrapped in curly braces and parsed as if it were a Yaml object. The keys of this object are then + * mapped to the parameters of the method via the usual Jackson mechanisms. The standard [java.lang.Object] methods are + * excluded. + * + * One convenient feature of Yaml is that barewords collapse into strings, thus you can write a call like the following: + * + * fun someCall(note: String, option: Boolean) + * + * someCall note: This is a really helpful feature, option: true + * + * ... and it will be parsed in the intuitive way. Quotes are only needed if you want to put a comma into the string. + * + * There is an [online Yaml parser](http://yaml-online-parser.appspot.com/) which can be used to explore + * the allowed syntax. + * + * # Usage + * + * This class is thread safe. Multiple strings may be parsed in parallel, and the resulting [ParsedMethodCall] + * objects may be reused multiple times and also invoked in parallel, as long as the underling target object is + * thread safe itself. + * + * You may pass in an alternative [ObjectMapper] to control what types can be parsed, but it must be configured + * with the [YAMLFactory] for the class to work. + * + * # Limitations + * + * - The target class must be either a Kotlin class, or a Java class compiled with the -parameters command line + * switch, as the class relies on knowing the names of parameters which isn't data provided by default by the + * Java compiler. + * - Vararg methods are not supported, as the type information that'd be required is missing. + * - Method overloads that have identical parameter names but different types can't be handled, because often + * a string could map to multiple types, so which one to use is ambiguous. If you want your interface to be + * usable with this utility make sure the parameter and method names don't rely on type overloading. + * + * # Examples + * + * fun simple() = ... + * "simple" -> runs the no-args function 'simple' + * + * fun attachmentExists(id: SecureHash): Boolean + * "attachmentExists id: b6d7e826e87" -> parses the given ID as a SecureHash + * + * fun addNote(id: SecureHash, note: String) + * "addNote id: b6d7e826e8739ab2eb6e077fc4fba9b04fb880bb4cbd09bc618d30234a8827a4, note: Some note" + */ +@ThreadSafe +open class StringToMethodCallParser(targetType: Class, + private val om: ObjectMapper = JacksonSupport.createNonRpcMapper(YAMLFactory())) { + /** Same as the regular constructor but takes a Kotlin reflection [KClass] instead of a Java [Class]. */ + constructor(targetType: KClass) : this(targetType.java) + + companion object { + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + private val ignoredNames = Object::class.java.methods.map { it.name } + private fun methodsFromType(clazz: Class<*>) = clazz.methods.map { it.name to it }.toMap().filterKeys { it !in ignoredNames } + private val log = LoggerFactory.getLogger(StringToMethodCallParser::class.java)!! + } + + /** The methods that can be invoked via this parser. */ + protected val methodMap = methodsFromType(targetType) + /** A map of method name to parameter names for the target type. */ + val methodParamNames: Map> = targetType.declaredMethods.mapNotNull { + try { + it.name to paramNamesFromMethod(it) + } catch(e: KotlinReflectionInternalError) { + // Kotlin reflection doesn't support every method that can exist on an object (in particular, reified + // inline methods) so we just ignore those here. + null + } + }.toMap() + + inner class ParsedMethodCall(private val target: T?, val methodName: String, val args: Array) : Callable { + operator fun invoke(): Any? = call() + override fun call(): Any? { + if (target == null) + throw IllegalStateException("No target object was specified") + if (log.isDebugEnabled) + log.debug("Invoking call $methodName($args)") + return methodMap[methodName]!!.invoke(target, *args) + } + } + + /** + * Uses either Kotlin or Java 8 reflection to learn the names of the parameters to a method. + */ + open fun paramNamesFromMethod(method: Method): List { + val kf: KFunction<*>? = method.kotlinFunction + return method.parameters.mapIndexed { index, param -> + when { + param.isNamePresent -> param.name + // index + 1 because the first Kotlin reflection param is 'this', but that doesn't match Java reflection. + kf != null -> kf.parameters[index + 1].name ?: throw UnparseableCallException.ReflectionDataMissing(method.name, index) + else -> throw UnparseableCallException.ReflectionDataMissing(method.name, index) + } + } + } + + /** + * Uses either Kotlin or Java 8 reflection to learn the names of the parameters to a constructor. + */ + open fun paramNamesFromConstructor(ctor: Constructor<*>): List { + val kf: KFunction<*>? = ctor.kotlinFunction + return ctor.parameters.mapIndexed { index, param -> + when { + param.isNamePresent -> param.name + kf != null -> kf.parameters[index].name ?: throw UnparseableCallException.ReflectionDataMissing("", index) + else -> throw UnparseableCallException.ReflectionDataMissing("", index) + } + } + } + + open class UnparseableCallException(command: String) : Exception("Could not parse as a command: $command") { + class UnknownMethod(val methodName: String) : UnparseableCallException("Unknown command name: $methodName") + class MissingParameter(methodName: String, val paramName: String, command: String) : UnparseableCallException("Parameter $paramName missing from attempt to invoke $methodName in command: $command") + class TooManyParameters(methodName: String, command: String) : UnparseableCallException("Too many parameters provided for $methodName: $command") + class ReflectionDataMissing(methodName: String, argIndex: Int) : UnparseableCallException("Method $methodName missing parameter name at index $argIndex") + } + + /** + * Parses the given command as a call on the target type. The target should be specified, if it's null then + * the resulting [ParsedMethodCall] can't be invoked, just inspected. + */ + @Throws(UnparseableCallException::class) + fun parse(target: T?, command: String): ParsedMethodCall { + log.debug("Parsing call command from string: {}", command) + val spaceIndex = command.indexOf(' ') + val name = if (spaceIndex != -1) command.substring(0, spaceIndex) else command + val argStr = if (spaceIndex != -1) command.substring(spaceIndex) else "" + val method = methodMap[name] ?: throw UnparseableCallException.UnknownMethod(name) + log.debug("Parsing call for method {}", name) + val args = parseArguments(name, paramNamesFromMethod(method).zip(method.parameterTypes), argStr) + return ParsedMethodCall(target, name, args) + } + + /** + * Parses only the arguments string given the info about parameter names and types. + * + * @param methodNameHint A name that will be used in exceptions if thrown; not used for any other purpose. + */ + @Throws(UnparseableCallException::class) + fun parseArguments(methodNameHint: String, parameters: List>>, args: String): Array { + // If we have parameters, wrap them in {} to allow the Yaml parser to eat them on a single line. + val parameterString = "{ $args }" + val tree: JsonNode = om.readTree(parameterString) ?: throw UnparseableCallException(args) + if (tree.size() > parameters.size) throw UnparseableCallException.TooManyParameters(methodNameHint, args) + val inOrderParams: List = parameters.mapIndexed { index, param -> + val (argName, argType) = param + val entry = tree[argName] ?: throw UnparseableCallException.MissingParameter(methodNameHint, argName, args) + om.readValue(entry.traverse(om), argType) + } + if (log.isDebugEnabled) { + inOrderParams.forEachIndexed { i, param -> + val tmp = if (param != null) "${param.javaClass.name} -> $param" else "(null)" + log.debug("Parameter $i. $tmp") + } + } + return inOrderParams.toTypedArray() + } +} \ No newline at end of file diff --git a/client/jackson/src/test/kotlin/net/corda/jackson/StringToMethodCallParserTest.kt b/client/jackson/src/test/kotlin/net/corda/jackson/StringToMethodCallParserTest.kt new file mode 100644 index 0000000000..d0e0c7cbb8 --- /dev/null +++ b/client/jackson/src/test/kotlin/net/corda/jackson/StringToMethodCallParserTest.kt @@ -0,0 +1,34 @@ +package net.corda.jackson + +import net.corda.core.crypto.SecureHash +import org.junit.Test +import kotlin.test.assertEquals + +class StringToMethodCallParserTest { + @Suppress("UNUSED") + class Target { + fun simple() = "simple" + fun string(note: String) = note + fun twoStrings(a: String, b: String) = a + b + fun simpleObject(hash: SecureHash.SHA256) = hash.toString()!! + fun complexObject(pair: Pair) = pair + } + + val randomHash = "361170110f61086f77ff2c5b7ab36513705da1a3ebabf14dbe5cc9c982c45401" + val tests = mapOf( + "simple" to "simple", + "string note: A test of barewords" to "A test of barewords", + "twoStrings a: Some words, b: ' and some words, like, Kirk, would, speak'" to "Some words and some words, like, Kirk, would, speak", + "simpleObject hash: $randomHash" to randomHash.toUpperCase(), + "complexObject pair: { first: 12, second: Word up brother }" to Pair(12, "Word up brother") + ) + + @Test + fun calls() { + val parser = StringToMethodCallParser(Target::class) + val target = Target() + for ((input, output) in tests) { + assertEquals(output, parser.parse(target, input).invoke()) + } + } +} \ No newline at end of file diff --git a/test-utils/src/main/kotlin/net/corda/testing/http/HttpUtils.kt b/test-utils/src/main/kotlin/net/corda/testing/http/HttpUtils.kt index 53f640029e..b5702c7b24 100644 --- a/test-utils/src/main/kotlin/net/corda/testing/http/HttpUtils.kt +++ b/test-utils/src/main/kotlin/net/corda/testing/http/HttpUtils.kt @@ -1,9 +1,9 @@ package net.corda.testing.http import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.KotlinModule import net.corda.core.utilities.loggerFor -import net.corda.jackson.JacksonSupport import okhttp3.MediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -22,7 +22,7 @@ object HttpUtils { .readTimeout(60, TimeUnit.SECONDS).build() } val defaultMapper: ObjectMapper by lazy { - ObjectMapper().registerModule(JacksonSupport.javaTimeModule).registerModule(KotlinModule()) + ObjectMapper().registerModule(JavaTimeModule()).registerModule(KotlinModule()) } fun putJson(url: URL, data: String) : Boolean {