Add a general string to method call parsing utility class to the Jackson module. It is useful for the shell work.

This commit is contained in:
Mike Hearn 2017-03-03 12:16:16 +01:00
parent d576468a93
commit b8a4c7bea3
4 changed files with 233 additions and 15 deletions

View File

@ -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)

View File

@ -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<in T : Any>(targetType: Class<out T>,
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<out T>) : 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<String, List<String>> = 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<Any?>) : Callable<Any?> {
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<String> {
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<String> {
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("<init>", index)
else -> throw UnparseableCallException.ReflectionDataMissing("<init>", 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<Pair<String, Class<*>>>, args: String): Array<Any?> {
// 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<Any?> = 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()
}
}

View File

@ -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<Int, String>) = 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())
}
}
}

View File

@ -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 {