CORDA-1456: Change output format in the shell (#4318)

* [CORDA-1456] Add support and switch for JSON/YAML output format
This commit is contained in:
Dimos Raptis 2018-11-30 10:14:26 +00:00 committed by GitHub
parent 8785cf449c
commit 11e7bf5bbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 285 additions and 22 deletions

View File

@ -310,6 +310,8 @@ Unreleased
* Finance CorDapp is now build as a sealed and signed JAR file. * Finance CorDapp is now build as a sealed and signed JAR file.
Custom classes can no longer be placed in the packages defined in Finance Cordapp or access it's non-public members. Custom classes can no longer be placed in the packages defined in Finance Cordapp or access it's non-public members.
* The format of the shell commands' output can now be customized via the node shell, using the ``output-format`` command.
Version 3.3 Version 3.3
----------- -----------

View File

@ -206,6 +206,18 @@ You can shut the node down via shell:
* ``gracefulShutdown`` will put node into draining mode, and shut down when there are no flows running * ``gracefulShutdown`` will put node into draining mode, and shut down when there are no flows running
* ``shutdown`` will shut the node down immediately * ``shutdown`` will shut the node down immediately
Output Formats
**********************
You can choose the format in which the output of the commands will be shown.
To see what is the format that's currently used, you can type ``output-format get``.
To update the format, you can type ``output-format set json``.
The currently supported formats are ``json``, ``yaml``. The default format is ``yaml``.
Flow commands Flow commands
************* *************

View File

@ -0,0 +1,59 @@
package net.corda.tools.shell;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.BiMap;
import com.google.common.collect.ImmutableBiMap;
import net.corda.tools.shell.InteractiveShell.OutputFormat;
import org.crsh.cli.Argument;
import org.crsh.cli.Command;
import org.crsh.cli.Man;
import org.crsh.cli.Usage;
import org.crsh.command.InvocationContext;
import org.crsh.command.ScriptException;
import org.crsh.text.RenderPrintWriter;
import java.util.Map;
@Man("Allows you to see and update the format that's currently used for the commands' output.")
public class OutputFormatCommand extends InteractiveShellCommand {
public OutputFormatCommand() {}
@VisibleForTesting
OutputFormatCommand(final RenderPrintWriter printWriter) {
this.out = printWriter;
}
private static final BiMap<String, OutputFormat> OUTPUT_FORMAT_MAPPING = ImmutableBiMap.of(
"json", OutputFormat.JSON,
"yaml", OutputFormat.YAML
);
@Command
@Man("Sets the output format of the commands.")
@Usage("sets the output format of the commands.")
public void set(InvocationContext<Map> context,
@Usage("The format of the commands output. Supported values: json, yaml.") @Argument String format) {
OutputFormat outputFormat = parseFormat(format);
InteractiveShell.setOutputFormat(outputFormat);
}
@Command
@Man("Shows the output format of the commands.")
@Usage("shows the output format of the commands.")
public void get(InvocationContext<Map> context) {
OutputFormat outputFormat = InteractiveShell.getOutputFormat();
final String format = OUTPUT_FORMAT_MAPPING.inverse().get(outputFormat);
out.println(format);
}
private OutputFormat parseFormat(String format) {
if (!OUTPUT_FORMAT_MAPPING.containsKey(format)) {
throw new ScriptException("The provided format is not supported: " + format);
}
return OUTPUT_FORMAT_MAPPING.get(format);
}
}

View File

@ -1,5 +1,6 @@
package net.corda.tools.shell; package net.corda.tools.shell;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import net.corda.client.jackson.StringToMethodCallParser; import net.corda.client.jackson.StringToMethodCallParser;
@ -36,7 +37,8 @@ public class RunShellCommand extends InteractiveShellCommand {
"consulting the developer guide at https://docs.corda.net/api/kotlin/corda/net.corda.core.messaging/-corda-r-p-c-ops/index.html" "consulting the developer guide at https://docs.corda.net/api/kotlin/corda/net.corda.core.messaging/-corda-r-p-c-ops/index.html"
) )
@Usage("runs a method from the CordaRPCOps interface on the node.") @Usage("runs a method from the CordaRPCOps interface on the node.")
public Object main(InvocationContext<Map> context, @Usage("The command to run") @Argument(unquote = false) List<String> command) { public Object main(InvocationContext<Map> context,
@Usage("The command to run") @Argument(unquote = false) List<String> command) {
logger.info("Executing command \"run {}\",", (command != null) ? command.stream().collect(joining(" ")) : "<no arguments>"); logger.info("Executing command \"run {}\",", (command != null) ? command.stream().collect(joining(" ")) : "<no arguments>");
StringToMethodCallParser<CordaRPCOps> parser = new StringToMethodCallParser<>(CordaRPCOps.class, objectMapper(InteractiveShell.getCordappsClassloader())); StringToMethodCallParser<CordaRPCOps> parser = new StringToMethodCallParser<>(CordaRPCOps.class, objectMapper(InteractiveShell.getCordappsClassloader()));
@ -44,6 +46,7 @@ public class RunShellCommand extends InteractiveShellCommand {
emitHelp(context, parser); emitHelp(context, parser);
return null; return null;
} }
return InteractiveShell.runRPCFromString(command, out, context, ops(), objectMapper(InteractiveShell.getCordappsClassloader())); return InteractiveShell.runRPCFromString(command, out, context, ops(), objectMapper(InteractiveShell.getCordappsClassloader()));
} }

View File

@ -1,9 +1,11 @@
package net.corda.tools.shell package net.corda.tools.shell
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator
import net.corda.client.jackson.JacksonSupport import net.corda.client.jackson.JacksonSupport
import net.corda.client.jackson.StringToMethodCallParser import net.corda.client.jackson.StringToMethodCallParser
import net.corda.client.rpc.CordaRPCClientConfiguration import net.corda.client.rpc.CordaRPCClientConfiguration
@ -61,7 +63,7 @@ import kotlin.concurrent.thread
// TODO: Add command history. // TODO: Add command history.
// TODO: Command completion. // TODO: Command completion.
// TODO: Do something sensible with commands that return a future. // TODO: Do something sensible with commands that return a future.
// TODO: Configure default renderers, send objects down the pipeline, add commands to do json/xml/yaml outputs. // TODO: Configure default renderers, send objects down the pipeline, add support for xml output format.
// TODO: Add a command to view last N lines/tail/control log4j2 loggers. // TODO: Add a command to view last N lines/tail/control log4j2 loggers.
// TODO: Review or fix the JVM commands which have bitrotted and some are useless. // TODO: Review or fix the JVM commands which have bitrotted and some are useless.
// TODO: Get rid of the 'java' command, it's kind of worthless. // TODO: Get rid of the 'java' command, it's kind of worthless.
@ -82,6 +84,11 @@ object InteractiveShell {
@JvmStatic @JvmStatic
fun getCordappsClassloader() = classLoader fun getCordappsClassloader() = classLoader
enum class OutputFormat {
JSON,
YAML
}
/** /**
* Starts an interactive shell connected to the local terminal. This shell gives administrator access to the node * Starts an interactive shell connected to the local terminal. This shell gives administrator access to the node
* internals. * internals.
@ -117,6 +124,7 @@ object InteractiveShell {
} }
} }
ExternalResolver.INSTANCE.addCommand("output-format", "Commands to inspect and update the output format.", OutputFormatCommand::class.java)
ExternalResolver.INSTANCE.addCommand("run", "Runs a method from the CordaRPCOps interface on the node.", RunShellCommand::class.java) ExternalResolver.INSTANCE.addCommand("run", "Runs a method from the CordaRPCOps interface on the node.", RunShellCommand::class.java)
ExternalResolver.INSTANCE.addCommand("flow", "Commands to work with flows. Flows are how you can change the ledger.", FlowShellCommand::class.java) ExternalResolver.INSTANCE.addCommand("flow", "Commands to work with flows. Flows are how you can change the ledger.", FlowShellCommand::class.java)
ExternalResolver.INSTANCE.addCommand("start", "An alias for 'flow start'", StartShellCommand::class.java) ExternalResolver.INSTANCE.addCommand("start", "An alias for 'flow start'", StartShellCommand::class.java)
@ -191,6 +199,16 @@ object InteractiveShell {
throw e.cause ?: e throw e.cause ?: e
} }
@JvmStatic
fun setOutputFormat(outputFormat: OutputFormat) {
this.outputFormat = outputFormat
}
@JvmStatic
fun getOutputFormat(): OutputFormat {
return outputFormat
}
fun createYamlInputMapper(rpcOps: CordaRPCOps): ObjectMapper { fun createYamlInputMapper(rpcOps: CordaRPCOps): ObjectMapper {
// Return a standard Corda Jackson object mapper, configured to use YAML by default and with extra // Return a standard Corda Jackson object mapper, configured to use YAML by default and with extra
// serializers. // serializers.
@ -203,8 +221,13 @@ object InteractiveShell {
} }
} }
private fun createOutputMapper(): ObjectMapper { private fun createOutputMapper(outputFormat: OutputFormat): ObjectMapper {
return JacksonSupport.createNonRpcMapper().apply { val factory = when(outputFormat) {
OutputFormat.JSON -> JsonFactory()
OutputFormat.YAML -> YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)
}
return JacksonSupport.createNonRpcMapper(factory).apply {
// Register serializers for stateful objects from libraries that are special to the RPC system and don't // Register serializers for stateful objects from libraries that are special to the RPC system and don't
// make sense to print out to the screen. For classes we own, annotations can be used instead. // make sense to print out to the screen. For classes we own, annotations can be used instead.
val rpcModule = SimpleModule().apply { val rpcModule = SimpleModule().apply {
@ -218,8 +241,8 @@ object InteractiveShell {
} }
} }
// TODO: This should become the default renderer rather than something used specifically by commands. // TODO: A default renderer could be used, instead of an object mapper. See: http://www.crashub.org/1.3/reference.html#_renderers
private val outputMapper by lazy { createOutputMapper() } private var outputFormat = OutputFormat.YAML
@VisibleForTesting @VisibleForTesting
lateinit var latch: CountDownLatch lateinit var latch: CountDownLatch
@ -236,7 +259,7 @@ object InteractiveShell {
output: RenderPrintWriter, output: RenderPrintWriter,
rpcOps: CordaRPCOps, rpcOps: CordaRPCOps,
ansiProgressRenderer: ANSIProgressRenderer, ansiProgressRenderer: ANSIProgressRenderer,
om: ObjectMapper = outputMapper) { inputObjectMapper: ObjectMapper = createYamlInputMapper(rpcOps)) {
val matches = try { val matches = try {
rpcOps.registeredFlows().filter { nameFragment in it } rpcOps.registeredFlows().filter { nameFragment in it }
} catch (e: PermissionException) { } catch (e: PermissionException) {
@ -261,7 +284,7 @@ object InteractiveShell {
try { try {
// Show the progress tracker on the console until the flow completes or is interrupted with a // Show the progress tracker on the console until the flow completes or is interrupted with a
// Ctrl-C keypress. // Ctrl-C keypress.
val stateObservable = runFlowFromString({ clazz, args -> rpcOps.startTrackedFlowDynamic(clazz, *args) }, inputData, flowClazz, om) val stateObservable = runFlowFromString({ clazz, args -> rpcOps.startTrackedFlowDynamic(clazz, *args) }, inputData, flowClazz, inputObjectMapper)
latch = CountDownLatch(1) latch = CountDownLatch(1)
ansiProgressRenderer.render(stateObservable, latch::countDown) ansiProgressRenderer.render(stateObservable, latch::countDown)
@ -415,7 +438,8 @@ object InteractiveShell {
} }
@JvmStatic @JvmStatic
fun runRPCFromString(input: List<String>, out: RenderPrintWriter, context: InvocationContext<out Any>, cordaRPCOps: CordaRPCOps, om: ObjectMapper): Any? { fun runRPCFromString(input: List<String>, out: RenderPrintWriter, context: InvocationContext<out Any>, cordaRPCOps: CordaRPCOps,
inputObjectMapper: ObjectMapper): Any? {
val cmd = input.joinToString(" ").trim { it <= ' ' } val cmd = input.joinToString(" ").trim { it <= ' ' }
if (cmd.startsWith("startflow", ignoreCase = true)) { if (cmd.startsWith("startflow", ignoreCase = true)) {
// The flow command provides better support and startFlow requires special handling anyway due to // The flow command provides better support and startFlow requires special handling anyway due to
@ -430,11 +454,11 @@ object InteractiveShell {
var result: Any? = null var result: Any? = null
try { try {
InputStreamSerializer.invokeContext = context InputStreamSerializer.invokeContext = context
val parser = StringToMethodCallParser(CordaRPCOps::class.java, om) val parser = StringToMethodCallParser(CordaRPCOps::class.java, inputObjectMapper)
val call = parser.parse(cordaRPCOps, cmd) val call = parser.parse(cordaRPCOps, cmd)
result = call.call() result = call.call()
if (result != null && result !== kotlin.Unit && result !is Void) { if (result != null && result !== kotlin.Unit && result !is Void) {
result = printAndFollowRPCResponse(result, out) result = printAndFollowRPCResponse(result, out, outputFormat)
} }
if (result is Future<*>) { if (result is Future<*>) {
if (!result.isDone) { if (!result.isDone) {
@ -530,19 +554,11 @@ object InteractiveShell {
} }
} }
private fun printAndFollowRPCResponse(response: Any?, out: PrintWriter): CordaFuture<Unit> { private fun printAndFollowRPCResponse(response: Any?, out: PrintWriter, outputFormat: OutputFormat): CordaFuture<Unit> {
val outputMapper = createOutputMapper(outputFormat)
val mapElement: (Any?) -> String = { element -> outputMapper.writerWithDefaultPrettyPrinter().writeValueAsString(element) } val mapElement: (Any?) -> String = { element -> outputMapper.writerWithDefaultPrettyPrinter().writeValueAsString(element) }
val mappingFunction: (Any?) -> String = { value -> return maybeFollow(response, mapElement, out)
if (value is Collection<*>) {
value.joinToString(",${System.lineSeparator()} ", "[${System.lineSeparator()} ", "${System.lineSeparator()}]") { element ->
mapElement(element)
}
} else {
mapElement(value)
}
}
return maybeFollow(response, mappingFunction, out)
} }
private class PrintingSubscriber(private val printerFun: (Any?) -> String, private val toStream: PrintWriter) : Subscriber<Any>() { private class PrintingSubscriber(private val printerFun: (Any?) -> String, private val toStream: PrintWriter) : Subscriber<Any>() {

View File

@ -0,0 +1,52 @@
package net.corda.tools.shell;
import org.crsh.command.InvocationContext;
import org.crsh.command.ScriptException;
import org.crsh.text.RenderPrintWriter;
import org.junit.Before;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
public class OutputFormatCommandTest {
private InvocationContext mockInvocationContext;
private RenderPrintWriter printWriter;
private OutputFormatCommand outputFormatCommand;
private static final String JSON_FORMAT_STRING = "json";
private static final String YAML_FORMAT_STRING = "yaml";
@Before
public void setup() {
mockInvocationContext = mock(InvocationContext.class);
printWriter = mock(RenderPrintWriter.class);
outputFormatCommand = new OutputFormatCommand(printWriter);
}
@Test
public void testValidUpdateToJson() {
outputFormatCommand.set(mockInvocationContext, JSON_FORMAT_STRING);
outputFormatCommand.get(mockInvocationContext);
verify(printWriter).println(JSON_FORMAT_STRING);
}
@Test
public void testValidUpdateToYaml() {
outputFormatCommand.set(mockInvocationContext, YAML_FORMAT_STRING);
outputFormatCommand.get(mockInvocationContext);
verify(printWriter).println(YAML_FORMAT_STRING);
}
@Test
public void testInvalidUpdate() {
assertThatExceptionOfType(ScriptException.class).isThrownBy(() -> outputFormatCommand.set(mockInvocationContext, "some-invalid-format"))
.withMessage("The provided format is not supported: some-invalid-format");
}
}

View File

@ -1,21 +1,36 @@
package net.corda.tools.shell package net.corda.tools.shell
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.type.TypeFactory
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.nhaarman.mockito_kotlin.mock
import com.nhaarman.mockito_kotlin.verify
import com.nhaarman.mockito_kotlin.whenever
import net.corda.client.jackson.JacksonSupport import net.corda.client.jackson.JacksonSupport
import net.corda.client.jackson.internal.ToStringSerialize import net.corda.client.jackson.internal.ToStringSerialize
import net.corda.core.contracts.Amount import net.corda.core.contracts.Amount
import net.corda.core.crypto.SecureHash import net.corda.core.crypto.SecureHash
import net.corda.core.crypto.generateKeyPair
import net.corda.core.flows.FlowLogic import net.corda.core.flows.FlowLogic
import net.corda.core.flows.StateMachineRunId import net.corda.core.flows.StateMachineRunId
import net.corda.core.identity.CordaX500Name import net.corda.core.identity.CordaX500Name
import net.corda.core.identity.Party import net.corda.core.identity.Party
import net.corda.core.internal.concurrent.openFuture import net.corda.core.internal.concurrent.openFuture
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.messaging.FlowProgressHandleImpl import net.corda.core.messaging.FlowProgressHandleImpl
import net.corda.core.node.NodeInfo
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.core.utilities.ProgressTracker import net.corda.core.utilities.ProgressTracker
import net.corda.node.services.identity.InMemoryIdentityService import net.corda.node.services.identity.InMemoryIdentityService
import net.corda.testing.core.ALICE_NAME
import net.corda.testing.core.BOB_NAME
import net.corda.testing.core.TestIdentity import net.corda.testing.core.TestIdentity
import net.corda.testing.core.getTestPartyAndCertificate
import net.corda.testing.internal.DEV_ROOT_CA import net.corda.testing.internal.DEV_ROOT_CA
import org.crsh.command.InvocationContext
import org.crsh.text.RenderPrintWriter
import org.junit.Before
import org.junit.Test import org.junit.Test
import rx.Observable import rx.Observable
import java.util.* import java.util.*
@ -23,8 +38,75 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
class InteractiveShellTest { class InteractiveShellTest {
lateinit var inputObjectMapper: ObjectMapper
lateinit var cordaRpcOps: CordaRPCOps
lateinit var invocationContext: InvocationContext<Map<Any, Any>>
lateinit var printWriter: RenderPrintWriter
@Before
fun setup() {
inputObjectMapper = objectMapperWithClassLoader(InteractiveShell.getCordappsClassloader())
cordaRpcOps = mock()
invocationContext = mock()
printWriter = mock()
}
companion object { companion object {
private val megaCorp = TestIdentity(CordaX500Name("MegaCorp", "London", "GB")) private val megaCorp = TestIdentity(CordaX500Name("MegaCorp", "London", "GB"))
private val ALICE = getTestPartyAndCertificate(ALICE_NAME, generateKeyPair().public)
private val BOB = getTestPartyAndCertificate(BOB_NAME, generateKeyPair().public)
private val ALICE_NODE_INFO = NodeInfo(listOf(NetworkHostAndPort("localhost", 8080)), listOf(ALICE), 1, 1)
private val BOB_NODE_INFO = NodeInfo(listOf(NetworkHostAndPort("localhost", 80)), listOf(BOB), 1, 1)
private val NODE_INFO_JSON_PAYLOAD =
"""
{
"addresses" : [ "localhost:8080" ],
"legalIdentitiesAndCerts" : [ "O=Alice Corp, L=Madrid, C=ES" ],
"platformVersion" : 1,
"serial" : 1
}
""".trimIndent()
private val NODE_INFO_YAML_PAYLOAD =
"""
addresses:
- "localhost:8080"
legalIdentitiesAndCerts:
- "O=Alice Corp, L=Madrid, C=ES"
platformVersion: 1
serial: 1
""".trimIndent()
private val NETWORK_MAP_JSON_PAYLOAD =
"""
[ {
"addresses" : [ "localhost:8080" ],
"legalIdentitiesAndCerts" : [ "O=Alice Corp, L=Madrid, C=ES" ],
"platformVersion" : 1,
"serial" : 1
}, {
"addresses" : [ "localhost:80" ],
"legalIdentitiesAndCerts" : [ "O=Bob Plc, L=Rome, C=IT" ],
"platformVersion" : 1,
"serial" : 1
} ]
""".trimIndent()
private val NETWORK_MAP_YAML_PAYLOAD =
"""
- addresses:
- "localhost:8080"
legalIdentitiesAndCerts:
- "O=Alice Corp, L=Madrid, C=ES"
platformVersion: 1
serial: 1
- addresses:
- "localhost:80"
legalIdentitiesAndCerts:
- "O=Bob Plc, L=Rome, C=IT"
platformVersion: 1
serial: 1
""".trimIndent()
} }
@Suppress("UNUSED") @Suppress("UNUSED")
@ -58,6 +140,14 @@ class InteractiveShellTest {
assertEquals(expected, output!!, input) assertEquals(expected, output!!, input)
} }
private fun objectMapperWithClassLoader(classLoader: ClassLoader?): ObjectMapper {
val objectMapper = ObjectMapper()
val tf = TypeFactory.defaultInstance().withClassLoader(classLoader)
objectMapper.typeFactory = tf
return objectMapper
}
@Test @Test
fun flowStartSimple() { fun flowStartSimple() {
check("a: Hi there", "Hi there") check("a: Hi there", "Hi there")
@ -125,6 +215,35 @@ class InteractiveShellTest {
@Test @Test
fun party() = check("party: \"${megaCorp.name}\"", megaCorp.name.toString()) fun party() = check("party: \"${megaCorp.name}\"", megaCorp.name.toString())
@Test
fun runRpcFromStringWithCustomTypeResult() {
val command = listOf("nodeInfo")
whenever(cordaRpcOps.nodeInfo()).thenReturn(ALICE_NODE_INFO)
InteractiveShell.setOutputFormat(InteractiveShell.OutputFormat.YAML)
InteractiveShell.runRPCFromString(command, printWriter, invocationContext, cordaRpcOps, inputObjectMapper)
verify(printWriter).println(NODE_INFO_YAML_PAYLOAD)
InteractiveShell.setOutputFormat(InteractiveShell.OutputFormat.JSON)
InteractiveShell.runRPCFromString(command, printWriter, invocationContext, cordaRpcOps, inputObjectMapper)
verify(printWriter).println(NODE_INFO_JSON_PAYLOAD)
}
@Test
fun runRpcFromStringWithCollectionsResult() {
val command = listOf("networkMapSnapshot")
whenever(cordaRpcOps.networkMapSnapshot()).thenReturn(listOf(ALICE_NODE_INFO, BOB_NODE_INFO))
InteractiveShell.setOutputFormat(InteractiveShell.OutputFormat.YAML)
InteractiveShell.runRPCFromString(command, printWriter, invocationContext, cordaRpcOps, inputObjectMapper)
verify(printWriter).println(NETWORK_MAP_YAML_PAYLOAD)
InteractiveShell.setOutputFormat(InteractiveShell.OutputFormat.JSON)
InteractiveShell.runRPCFromString(command, printWriter, invocationContext, cordaRpcOps, inputObjectMapper)
verify(printWriter).println(NETWORK_MAP_JSON_PAYLOAD)
}
@ToStringSerialize @ToStringSerialize
data class UserValue(@JsonProperty("label") val label: String) { data class UserValue(@JsonProperty("label") val label: String) {
override fun toString() = label override fun toString() = label