diff --git a/.ci/api-current.txt b/.ci/api-current.txt index 976152d456..b0e292868f 100644 --- a/.ci/api-current.txt +++ b/.ci/api-current.txt @@ -5767,7 +5767,7 @@ public class net.corda.client.jackson.StringToMethodCallParser extends java.lang @NotNull public final net.corda.client.jackson.StringToMethodCallParser$ParsedMethodCall parse(T, String) @NotNull - public final Object[] parseArguments(String, java.util.List>>, String) + public final Object[] parseArguments(String, java.util.List>, String) public static final net.corda.client.jackson.StringToMethodCallParser$Companion Companion ## public static final class net.corda.client.jackson.StringToMethodCallParser$Companion extends java.lang.Object diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 5a65eea1d8..8fd9ac3695 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -275,6 +275,8 @@ + + diff --git a/client/jackson/src/main/kotlin/net/corda/client/jackson/JacksonSupport.kt b/client/jackson/src/main/kotlin/net/corda/client/jackson/JacksonSupport.kt index 3e7a1b13a1..549c79f72e 100644 --- a/client/jackson/src/main/kotlin/net/corda/client/jackson/JacksonSupport.kt +++ b/client/jackson/src/main/kotlin/net/corda/client/jackson/JacksonSupport.kt @@ -423,10 +423,10 @@ object JacksonSupport { } } - @Deprecated("This is an internal class, do not use") + @Deprecated("Do not use - Replaced by Corda's internal AmountDeserializer and TokenDeserializer classes") object AmountDeserializer : JsonDeserializer>() { override fun deserialize(parser: JsonParser, context: DeserializationContext): Amount<*> { - return if (parser.currentToken == JsonToken.VALUE_STRING) { + return if (parser.currentToken() == JsonToken.VALUE_STRING) { Amount.parseCurrency(parser.text) } else { val wrapper = parser.readValueAs() diff --git a/client/jackson/src/main/kotlin/net/corda/client/jackson/StringToMethodCallParser.kt b/client/jackson/src/main/kotlin/net/corda/client/jackson/StringToMethodCallParser.kt index a5c458fb89..86e1da9de1 100644 --- a/client/jackson/src/main/kotlin/net/corda/client/jackson/StringToMethodCallParser.kt +++ b/client/jackson/src/main/kotlin/net/corda/client/jackson/StringToMethodCallParser.kt @@ -20,6 +20,7 @@ import net.corda.core.CordaException import net.corda.core.utilities.contextLogger import java.lang.reflect.Constructor import java.lang.reflect.Method +import java.lang.reflect.Type import java.util.concurrent.Callable import javax.annotation.concurrent.ThreadSafe import kotlin.reflect.KClass @@ -183,7 +184,7 @@ open class StringToMethodCallParser @JvmOverloads constructor( // and fail for that too. for ((index, method) in methods.withIndex()) { try { - val args = parseArguments(name, paramNamesFromMethod(method).zip(method.parameterTypes), argStr) + val args = parseArguments(name, paramNamesFromMethod(method).zip(method.genericParameterTypes), argStr) return ParsedMethodCall(target, method, args) } catch (e: UnparseableCallException) { if (index == methods.size - 1) @@ -199,15 +200,16 @@ open class StringToMethodCallParser @JvmOverloads constructor( * @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 { + 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 { _, (argName, argType) -> val entry = tree[argName] ?: throw UnparseableCallException.MissingParameter(methodNameHint, argName, args) + val entryType = om.typeFactory.constructType(argType) try { - om.readValue(entry.traverse(om), argType) + om.readValue(entry.traverse(om), entryType) } catch (e: Exception) { throw UnparseableCallException.FailedParse(e) } diff --git a/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/CordaModule.kt b/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/CordaModule.kt index 32ca33befe..2212638551 100644 --- a/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/CordaModule.kt +++ b/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/CordaModule.kt @@ -2,10 +2,9 @@ package net.corda.client.jackson.internal -import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.* +import com.fasterxml.jackson.annotation.JsonCreator.Mode.* import com.fasterxml.jackson.annotation.JsonInclude.Include -import com.fasterxml.jackson.annotation.JsonTypeInfo -import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.core.JsonParser @@ -13,6 +12,9 @@ import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier +import com.fasterxml.jackson.databind.deser.ContextualDeserializer +import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.node.IntNode import com.fasterxml.jackson.databind.node.ObjectNode @@ -40,15 +42,18 @@ import net.corda.serialization.internal.amqp.SerializerFactory import net.corda.serialization.internal.amqp.constructorForDeserialization import net.corda.serialization.internal.amqp.hasCordaSerializable import net.corda.serialization.internal.amqp.propertiesForSerialization +import java.math.BigDecimal import java.security.PublicKey import java.security.cert.CertPath import java.time.Instant +import java.util.* class CordaModule : SimpleModule("corda-core") { override fun setupModule(context: SetupContext) { super.setupModule(context) context.addBeanSerializerModifier(CordaSerializableBeanSerializerModifier()) + context.addBeanDeserializerModifier(AmountBeanDeserializerModifier()) context.setMixInAnnotations(PartyAndCertificate::class.java, PartyAndCertificateMixin::class.java) context.setMixInAnnotations(NetworkHostAndPort::class.java, NetworkHostAndPortMixin::class.java) @@ -407,9 +412,78 @@ private interface SecureHashSHA256Mixin @JsonDeserialize(using = JacksonSupport.PublicKeyDeserializer::class) private interface PublicKeyMixin +@Suppress("unused_parameter") @ToStringSerialize -@JsonDeserialize(using = JacksonSupport.AmountDeserializer::class) -private interface AmountMixin +private abstract class AmountMixin @JsonCreator(mode = DISABLED) constructor( + quantity: Long, + displayTokenSize: BigDecimal, + token: Any +) { + /** + * This mirrors the [Amount] constructor that we want Jackson to use, and + * requires that we also tell Jackson NOT to use [Amount]'s primary constructor. + */ + @JsonCreator constructor( + @JsonProperty("quantity") + quantity: Long, + + @JsonDeserialize(using = TokenDeserializer::class) + @JsonProperty("token") + token: Any + ) : this(quantity, Amount.getDisplayTokenSize(token), token) +} + +/** + * Implements polymorphic deserialization for [Amount.token]. Kotlin must + * be able to determine the concrete [Amount] type at runtime, or it will + * fall back to using [Currency]. + */ +private class TokenDeserializer(private val tokenType: Class<*>) : JsonDeserializer(), ContextualDeserializer { + @Suppress("unused") + constructor() : this(Currency::class.java) + + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): Any = parser.readValueAs(tokenType) + + override fun createContextual(ctxt: DeserializationContext, property: BeanProperty?): TokenDeserializer { + if (property == null) return this + return TokenDeserializer(property.type.rawClass.let { type -> + if (type == Any::class.java) Currency::class.java else type + }) + } +} + +/** + * Intercepts bean-based deserialization for the generic [Amount] type. + */ +private class AmountBeanDeserializerModifier : BeanDeserializerModifier() { + override fun modifyDeserializer(config: DeserializationConfig, description: BeanDescription, deserializer: JsonDeserializer<*>): JsonDeserializer<*> { + val modified = super.modifyDeserializer(config, description, deserializer) + return if (Amount::class.java.isAssignableFrom(description.beanClass)) { + AmountDeserializer(modified) + } else { + modified + } + } +} + +private class AmountDeserializer(delegate: JsonDeserializer<*>) : DelegatingDeserializer(delegate) { + override fun newDelegatingInstance(newDelegatee: JsonDeserializer<*>) = AmountDeserializer(newDelegatee) + + override fun deserialize(parser: JsonParser, context: DeserializationContext?): Any { + return if (parser.currentToken() == JsonToken.VALUE_STRING) { + /* + * This is obviously specific to Amount, and is here to + * preserve the original deserializing behaviour for this case. + */ + Amount.parseCurrency(parser.text) + } else { + /* + * Otherwise continue deserializing our Bean as usual. + */ + _delegatee.deserialize(parser, context) + } + } +} @JsonDeserialize(using = JacksonSupport.OpaqueBytesDeserializer::class) private interface ByteSequenceMixin { diff --git a/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/JacksonUtils.kt b/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/JacksonUtils.kt index 255aa85855..30aa1c8d82 100644 --- a/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/JacksonUtils.kt +++ b/client/jackson/src/main/kotlin/net/corda/client/jackson/internal/JacksonUtils.kt @@ -3,10 +3,7 @@ package net.corda.client.jackson.internal import com.fasterxml.jackson.annotation.JacksonAnnotationsInside import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.annotation.JsonSerialize import com.fasterxml.jackson.databind.ser.std.ToStringSerializer import com.fasterxml.jackson.module.kotlin.convertValue diff --git a/client/jackson/src/test/kotlin/net/corda/client/jackson/AmountTest.kt b/client/jackson/src/test/kotlin/net/corda/client/jackson/AmountTest.kt new file mode 100644 index 0000000000..967ef28df6 --- /dev/null +++ b/client/jackson/src/test/kotlin/net/corda/client/jackson/AmountTest.kt @@ -0,0 +1,88 @@ +package net.corda.client.jackson + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.TextNode +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import net.corda.client.jackson.internal.CordaModule +import net.corda.core.contracts.Amount +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.util.* + +class AmountTest { + private companion object { + private val CO2 = CarbonCredit("CO2") + private val jsonMapper: ObjectMapper = ObjectMapper().registerModule(CordaModule()) + private val yamlMapper: ObjectMapper = ObjectMapper(YAMLFactory()).registerModule(CordaModule()) + } + + @Test + fun `Amount(Currency) JSON deserialization`() { + val str = """{ "quantity": 100, "token": "USD" }""" + val amount = jsonMapper.readValue>(str, object : TypeReference>() {}) + assertThat(amount.quantity).isEqualTo(100) + assertThat(amount.token).isEqualTo(Currency.getInstance("USD")) + } + + @Test + fun `Amount(Currency) YAML deserialization`() { + val str = """{ quantity: 100, token: USD }""" + val amount = yamlMapper.readValue>(str, object : TypeReference>() {}) + assertThat(amount.quantity).isEqualTo(100) + assertThat(amount.token).isEqualTo(Currency.getInstance("USD")) + } + + @Test + fun `Amount(CarbonCredit) JSON deserialization`() { + val str = """{ "quantity": 200, "token": { "type": "CO2" } }""" + val amount = jsonMapper.readValue>(str, object : TypeReference>() {}) + assertThat(amount.quantity).isEqualTo(200) + assertThat(amount.token).isEqualTo(CO2) + } + + @Test + fun `Amount(CarbonCredit) YAML deserialization`() { + val str = """{ quantity: 250, token: { type: CO2 } }""" + val amount = yamlMapper.readValue>(str, object : TypeReference>() {}) + assertThat(amount.quantity).isEqualTo(250) + assertThat(amount.token).isEqualTo(CO2) + } + + @Test + fun `Amount(Unknown) JSON deserialization`() { + val str = """{ "quantity": 100, "token": "USD" }""" + val amount = jsonMapper.readValue>(str, object : TypeReference>() {}) + assertThat(amount.quantity).isEqualTo(100) + assertThat(amount.token).isEqualTo(Currency.getInstance("USD")) + } + + @Test + fun `Amount(Unknown) YAML deserialization`() { + val str = """{ quantity: 100, token: USD }""" + val amount = yamlMapper.readValue>(str, object : TypeReference>() {}) + assertThat(amount.quantity).isEqualTo(100) + assertThat(amount.token).isEqualTo(Currency.getInstance("USD")) + } + + @Test + fun `Amount(Currency) YAML serialization`() { + assertThat(yamlMapper.valueToTree(Amount.parseCurrency("£25000000"))).isEqualTo(TextNode("25000000.00 GBP")) + assertThat(yamlMapper.valueToTree(Amount.parseCurrency("$250000"))).isEqualTo(TextNode("250000.00 USD")) + } + + @Test + fun `Amount(CarbonCredit) JSON serialization`() { + assertThat(jsonMapper.writeValueAsString(Amount(123456, CO2)).trim()) + .isEqualTo(""""123456 CarbonCredit(type=CO2)"""") + } + + @Test + fun `Amount(CarbonCredit) YAML serialization`() { + assertThat(yamlMapper.writeValueAsString(Amount(123456, CO2)).trim()) + .isEqualTo("""--- "123456 CarbonCredit(type=CO2)"""") + } + + data class CarbonCredit(@JsonProperty("type") val type: String) +} \ No newline at end of file diff --git a/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt b/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt index 29e237df2c..f042851332 100644 --- a/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt +++ b/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt @@ -105,10 +105,14 @@ class JacksonSupportTest(@Suppress("unused") private val name: String, factory: @Test fun `Amount(Currency) deserialization`() { val old = mapOf( - "quantity" to 2500000000, - "token" to "USD" + "quantity" to 2500000000, + "token" to "USD" ) assertThat(mapper.convertValue>(old)).isEqualTo(Amount(2_500_000_000, USD)) + } + + @Test + fun `Amount(Currency) Text deserialization`() { assertThat(mapper.convertValue>(TextNode("$25000000"))).isEqualTo(Amount(2_500_000_000, USD)) } diff --git a/client/jackson/src/test/kotlin/net/corda/client/jackson/StringToMethodCallParserTest.kt b/client/jackson/src/test/kotlin/net/corda/client/jackson/StringToMethodCallParserTest.kt index 24ba7b7c72..8b38525a34 100644 --- a/client/jackson/src/test/kotlin/net/corda/client/jackson/StringToMethodCallParserTest.kt +++ b/client/jackson/src/test/kotlin/net/corda/client/jackson/StringToMethodCallParserTest.kt @@ -13,7 +13,9 @@ package net.corda.client.jackson import net.corda.core.crypto.SecureHash import org.junit.Assert.assertArrayEquals import org.junit.Test +import java.util.* import kotlin.test.assertEquals +import kotlin.test.assertTrue class StringToMethodCallParserTest { @Suppress("UNUSED") @@ -23,6 +25,7 @@ class StringToMethodCallParserTest { fun twoStrings(a: String, b: String) = a + b fun simpleObject(hash: SecureHash.SHA256) = hash.toString() fun complexObject(pair: Pair) = pair + fun complexNestedObject(pairs: Pair>) = pairs fun overload(a: String) = a fun overload(a: String, b: String) = a + b @@ -48,30 +51,68 @@ class StringToMethodCallParserTest { } } + /* + * It would be unreasonable to expect "[ A, B, C ]" to deserialise as "Deque" by default. + * Deque is chosen as we still expect it to preserve the order of its elements. + */ + @Test + fun complexNestedGenericMethod() { + val parser = StringToMethodCallParser(Target::class) + val result = parser.parse(Target(), "complexNestedObject pairs: { first: 101, second: [ A, B, C ] }").invoke() + + assertTrue(result is Pair<*,*>) + result as Pair<*,*> + + assertEquals(101, result.first) + + assertTrue(result.second is Deque<*>) + val deque = result.second as Deque<*> + assertArrayEquals(arrayOf('A', 'B', 'C'), deque.toTypedArray()) + } + @Suppress("UNUSED") class ConstructorTarget(val someWord: String, val aDifferentThing: Int) { constructor(alternativeWord: String) : this(alternativeWord, 0) + constructor(numbers: List) : this(numbers.map(Long::toString).joinToString("+"), numbers.size) } @Test fun ctor1() { val clazz = ConstructorTarget::class.java val parser = StringToMethodCallParser(clazz) - val ctor = clazz.constructors.single { it.parameterCount == 2 } + val ctor = clazz.getDeclaredConstructor(String::class.java, Int::class.java) val names: List = parser.paramNamesFromConstructor(ctor) assertEquals(listOf("someWord", "aDifferentThing"), names) val args: Array = parser.parseArguments(clazz.name, names.zip(ctor.parameterTypes), "someWord: Blah blah blah, aDifferentThing: 12") - assertArrayEquals(args, arrayOf("Blah blah blah", 12)) + assertArrayEquals(arrayOf("Blah blah blah", 12), args) } @Test fun ctor2() { val clazz = ConstructorTarget::class.java val parser = StringToMethodCallParser(clazz) - val ctor = clazz.constructors.single { it.parameterCount == 1 } + val ctor = clazz.getDeclaredConstructor(String::class.java) val names: List = parser.paramNamesFromConstructor(ctor) assertEquals(listOf("alternativeWord"), names) val args: Array = parser.parseArguments(clazz.name, names.zip(ctor.parameterTypes), "alternativeWord: Foo bar!") - assertArrayEquals(args, arrayOf("Foo bar!")) + assertArrayEquals(arrayOf("Foo bar!"), args) + } + + @Test + fun constructorWithGenericArgs() { + val clazz = ConstructorTarget::class.java + val ctor = clazz.getDeclaredConstructor(List::class.java) + StringToMethodCallParser(clazz).apply { + val names = paramNamesFromConstructor(ctor) + assertEquals(listOf("numbers"), names) + + val commandLine = "numbers: [ 1, 2, 3 ]" + + val args = parseArguments(clazz.name, names.zip(ctor.parameterTypes), commandLine) + assertArrayEquals(arrayOf(listOf(1, 2, 3)), args) + + val trueArgs = parseArguments(clazz.name, names.zip(ctor.genericParameterTypes), commandLine) + assertArrayEquals(arrayOf(listOf(1L, 2L, 3L)), trueArgs) + } } } diff --git a/core-deterministic/testing/data/build.gradle b/core-deterministic/testing/data/build.gradle index 7f4b4d9f91..7af28b63bc 100644 --- a/core-deterministic/testing/data/build.gradle +++ b/core-deterministic/testing/data/build.gradle @@ -32,6 +32,7 @@ test { includeTestsMatching "net.corda.deterministic.data.GenerateData" } } +assemble.finalizedBy test artifacts { testData file: file("$buildDir/test-data.jar"), type: 'jar', builtBy: test diff --git a/core/src/main/kotlin/net/corda/core/context/InvocationContext.kt b/core/src/main/kotlin/net/corda/core/context/InvocationContext.kt index 5a8653f272..5805efc25d 100644 --- a/core/src/main/kotlin/net/corda/core/context/InvocationContext.kt +++ b/core/src/main/kotlin/net/corda/core/context/InvocationContext.kt @@ -113,7 +113,8 @@ sealed class InvocationOrigin { /** * Origin was an RPC call. */ - data class RPC(private val actor: Actor) : InvocationOrigin() { + // Field `actor` needs to stay public for AMQP / JSON serialization to work. + data class RPC(val actor: Actor) : InvocationOrigin() { override fun principal() = Principal { actor.id.value } } diff --git a/core/src/main/kotlin/net/corda/core/node/services/VaultService.kt b/core/src/main/kotlin/net/corda/core/node/services/VaultService.kt index 4e131785fb..d08f8db11e 100644 --- a/core/src/main/kotlin/net/corda/core/node/services/VaultService.kt +++ b/core/src/main/kotlin/net/corda/core/node/services/VaultService.kt @@ -21,6 +21,7 @@ import net.corda.core.flows.FlowLogic import net.corda.core.identity.AbstractParty import net.corda.core.internal.concurrent.doneFuture import net.corda.core.messaging.DataFeed +import net.corda.core.node.services.Vault.StateModificationStatus.* import net.corda.core.node.services.Vault.StateStatus import net.corda.core.node.services.vault.* import net.corda.core.serialization.CordaSerializable @@ -115,6 +116,26 @@ class Vault(val states: Iterable>) { UNCONSUMED, CONSUMED, ALL } + /** + * If the querying node is a participant in a state then it is classed as [MODIFIABLE], although technically the + * state is only _potentially_ modifiable as the contract code may forbid them from performing any actions. + * + * If the querying node is not a participant in a state then it is classed as [NOT_MODIFIABLE]. These types of + * states can still be recorded in the vault if the transaction containing them was recorded with the + * [StatesToRecord.ALL_VISIBLE] flag. This will typically happen for things like reference data which can be + * referenced in transactions as a [ReferencedStateAndRef] but cannot be modified by any party but the maintainer. + * + * If both [MODIFIABLE] and [NOT_MODIFIABLE] states are required to be returned from a query, then the [ALL] flag + * can be used. + * + * NOTE: Default behaviour is for ALL STATES to be returned as this is how Corda behaved before the introduction of + * this query criterion. + */ + @CordaSerializable + enum class StateModificationStatus { + MODIFIABLE, NOT_MODIFIABLE, ALL + } + @CordaSerializable enum class UpdateType { GENERAL, NOTARY_CHANGE, CONTRACT_UPGRADE @@ -141,14 +162,40 @@ class Vault(val states: Iterable>) { val otherResults: List) @CordaSerializable - data class StateMetadata(val ref: StateRef, - val contractStateClassName: String, - val recordedTime: Instant, - val consumedTime: Instant?, - val status: Vault.StateStatus, - val notary: AbstractParty?, - val lockId: String?, - val lockUpdateTime: Instant?) + data class StateMetadata constructor( + val ref: StateRef, + val contractStateClassName: String, + val recordedTime: Instant, + val consumedTime: Instant?, + val status: Vault.StateStatus, + val notary: AbstractParty?, + val lockId: String?, + val lockUpdateTime: Instant?, + val isModifiable: Vault.StateModificationStatus? + ) { + constructor(ref: StateRef, + contractStateClassName: String, + recordedTime: Instant, + consumedTime: Instant?, + status: Vault.StateStatus, + notary: AbstractParty?, + lockId: String?, + lockUpdateTime: Instant? + ) : this(ref, contractStateClassName, recordedTime, consumedTime, status, notary, lockId, lockUpdateTime, null) + + fun copy( + ref: StateRef = this.ref, + contractStateClassName: String = this.contractStateClassName, + recordedTime: Instant = this.recordedTime, + consumedTime: Instant? = this.consumedTime, + status: Vault.StateStatus = this.status, + notary: AbstractParty? = this.notary, + lockId: String? = this.lockId, + lockUpdateTime: Instant? = this.lockUpdateTime + ): StateMetadata { + return StateMetadata(ref, contractStateClassName, recordedTime, consumedTime, status, notary, lockId, lockUpdateTime, null) + } + } companion object { @Deprecated("No longer used. The vault does not emit empty updates") @@ -191,7 +238,10 @@ interface VaultService { */ @DeleteForDJVM fun whenConsumed(ref: StateRef): CordaFuture> { - val query = QueryCriteria.VaultQueryCriteria(stateRefs = listOf(ref), status = Vault.StateStatus.CONSUMED) + val query = QueryCriteria.VaultQueryCriteria( + stateRefs = listOf(ref), + status = Vault.StateStatus.CONSUMED + ) val result = trackBy(query) val snapshot = result.snapshot.states return if (snapshot.isNotEmpty()) { diff --git a/core/src/main/kotlin/net/corda/core/node/services/vault/QueryCriteria.kt b/core/src/main/kotlin/net/corda/core/node/services/vault/QueryCriteria.kt index 1e1f16a0e0..76732d12be 100644 --- a/core/src/main/kotlin/net/corda/core/node/services/vault/QueryCriteria.kt +++ b/core/src/main/kotlin/net/corda/core/node/services/vault/QueryCriteria.kt @@ -83,6 +83,7 @@ sealed class QueryCriteria : GenericQueryCriteria>? override fun visit(parser: IQueryCriteriaParser): Collection { return parser.parseCriteria(this) @@ -92,51 +93,124 @@ sealed class QueryCriteria : GenericQueryCriteria>? = null, - val stateRefs: List? = null, - val notary: List? = null, - val softLockingCondition: SoftLockingCondition? = null, - val timeCondition: TimeCondition? = null) : CommonQueryCriteria() { + data class VaultQueryCriteria @JvmOverloads constructor( + override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, + override val contractStateTypes: Set>? = null, + val stateRefs: List? = null, + val notary: List? = null, + val softLockingCondition: SoftLockingCondition? = null, + val timeCondition: TimeCondition? = null, + override val isModifiable: Vault.StateModificationStatus = Vault.StateModificationStatus.ALL + ) : CommonQueryCriteria() { override fun visit(parser: IQueryCriteriaParser): Collection { super.visit(parser) return parser.parseCriteria(this) } + + fun copy( + status: Vault.StateStatus = this.status, + contractStateTypes: Set>? = this.contractStateTypes, + stateRefs: List? = this.stateRefs, + notary: List? = this.notary, + softLockingCondition: SoftLockingCondition? = this.softLockingCondition, + timeCondition: TimeCondition? = this.timeCondition + ): VaultQueryCriteria { + return VaultQueryCriteria( + status, + contractStateTypes, + stateRefs, + notary, + softLockingCondition, + timeCondition + ) + } } /** * LinearStateQueryCriteria: provides query by attributes defined in [VaultSchema.VaultLinearState] */ - data class LinearStateQueryCriteria @JvmOverloads constructor(val participants: List? = null, - val uuid: List? = null, - val externalId: List? = null, - override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, - override val contractStateTypes: Set>? = null) : CommonQueryCriteria() { - constructor(participants: List? = null, - linearId: List? = null, - status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, - contractStateTypes: Set>? = null) : this(participants, linearId?.map { it.id }, linearId?.mapNotNull { it.externalId }, status, contractStateTypes) + data class LinearStateQueryCriteria @JvmOverloads constructor( + val participants: List? = null, + val uuid: List? = null, + val externalId: List? = null, + override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, + override val contractStateTypes: Set>? = null, + override val isModifiable: Vault.StateModificationStatus = Vault.StateModificationStatus.ALL + ) : CommonQueryCriteria() { + constructor( + participants: List? = null, + linearId: List? = null, + status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, + contractStateTypes: Set>? = null, + isRelevant: Vault.StateModificationStatus + ) : this(participants, linearId?.map { it.id }, linearId?.mapNotNull { it.externalId }, status, contractStateTypes, isRelevant) + + constructor( + participants: List? = null, + linearId: List? = null, + status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, + contractStateTypes: Set>? = null + ) : this(participants, linearId?.map { it.id }, linearId?.mapNotNull { it.externalId }, status, contractStateTypes) override fun visit(parser: IQueryCriteriaParser): Collection { super.visit(parser) return parser.parseCriteria(this) } + + fun copy( + participants: List? = this.participants, + uuid: List? = this.uuid, + externalId: List? = this.externalId, + status: Vault.StateStatus = this.status, + contractStateTypes: Set>? = this.contractStateTypes + ): LinearStateQueryCriteria { + return LinearStateQueryCriteria( + participants, + uuid, + externalId, + status, + contractStateTypes + ) + } } /** * FungibleStateQueryCriteria: provides query by attributes defined in [VaultSchema.VaultFungibleStates] */ - data class FungibleAssetQueryCriteria @JvmOverloads constructor(val participants: List? = null, - val owner: List? = null, - val quantity: ColumnPredicate? = null, - val issuer: List? = null, - val issuerRef: List? = null, - override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, - override val contractStateTypes: Set>? = null) : CommonQueryCriteria() { + data class FungibleAssetQueryCriteria @JvmOverloads constructor( + val participants: List? = null, + val owner: List? = null, + val quantity: ColumnPredicate? = null, + val issuer: List? = null, + val issuerRef: List? = null, + override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, + override val contractStateTypes: Set>? = null, + override val isModifiable: Vault.StateModificationStatus = Vault.StateModificationStatus.ALL + ) : CommonQueryCriteria() { override fun visit(parser: IQueryCriteriaParser): Collection { super.visit(parser) return parser.parseCriteria(this) } + + fun copy( + participants: List? = this.participants, + owner: List? = this.owner, + quantity: ColumnPredicate? = this.quantity, + issuer: List? = this.issuer, + issuerRef: List? = this.issuerRef, + status: Vault.StateStatus = this.status, + contractStateTypes: Set>? = this.contractStateTypes + ): FungibleAssetQueryCriteria { + return FungibleAssetQueryCriteria( + participants, + owner, + quantity, + issuer, + issuerRef, + status, + contractStateTypes + ) + } } /** @@ -147,14 +221,28 @@ sealed class QueryCriteria : GenericQueryCriteria @JvmOverloads constructor - (val expression: CriteriaExpression, - override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, - override val contractStateTypes: Set>? = null) : CommonQueryCriteria() { + data class VaultCustomQueryCriteria @JvmOverloads constructor( + val expression: CriteriaExpression, + override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED, + override val contractStateTypes: Set>? = null, + override val isModifiable: Vault.StateModificationStatus = Vault.StateModificationStatus.ALL + ) : CommonQueryCriteria() { override fun visit(parser: IQueryCriteriaParser): Collection { super.visit(parser) return parser.parseCriteria(this) } + + fun copy( + expression: CriteriaExpression = this.expression, + status: Vault.StateStatus = this.status, + contractStateTypes: Set>? = this.contractStateTypes + ): VaultCustomQueryCriteria { + return VaultCustomQueryCriteria( + expression, + status, + contractStateTypes + ) + } } // timestamps stored in the vault states table [VaultSchema.VaultStates] diff --git a/core/src/main/kotlin/net/corda/core/utilities/Try.kt b/core/src/main/kotlin/net/corda/core/utilities/Try.kt index 686c7e228d..3a434cd954 100644 --- a/core/src/main/kotlin/net/corda/core/utilities/Try.kt +++ b/core/src/main/kotlin/net/corda/core/utilities/Try.kt @@ -13,8 +13,6 @@ package net.corda.core.utilities import net.corda.core.KeepForDJVM import net.corda.core.internal.uncheckedCast import net.corda.core.serialization.CordaSerializable -import net.corda.core.utilities.Try.Failure -import net.corda.core.utilities.Try.Success /** * Representation of an operation that has either succeeded with a result (represented by [Success]) or failed with an @@ -70,6 +68,35 @@ sealed class Try { is Failure -> uncheckedCast(this) } + /** Applies the given action to the value if [Success], or does nothing if [Failure]. Returns `this` for chaining. */ + fun doOnSuccess(action: (A) -> Unit): Try { + when (this) { + is Success -> action.invoke(value) + is Failure -> {} + } + return this + } + + /** Applies the given action to the error if [Failure], or does nothing if [Success]. Returns `this` for chaining. */ + fun doOnFailure(action: (Throwable) -> Unit): Try { + when (this) { + is Success -> {} + is Failure -> action.invoke(exception) + } + return this + } + + /** Applies the given action to the exception if [Failure], rethrowing [Error]s. Does nothing if [Success]. Returns `this` for chaining. */ + fun doOnException(action: (Exception) -> Unit): Try { + return doOnFailure { error -> + if (error is Exception) { + action.invoke(error) + } else { + throw error + } + } + } + @KeepForDJVM data class Success(val value: A) : Try() { override val isSuccess: Boolean get() = true diff --git a/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt b/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt index fc9c26b230..b4237b1b93 100644 --- a/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt +++ b/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt @@ -5,6 +5,7 @@ import net.corda.core.contracts.* import net.corda.core.identity.AbstractParty import net.corda.core.identity.Party import net.corda.core.node.StatesToRecord +import net.corda.core.node.services.Vault import net.corda.core.node.services.queryBy import net.corda.core.node.services.vault.QueryCriteria import net.corda.core.transactions.LedgerTransaction @@ -102,7 +103,10 @@ internal class UseRefState(val linearId: UniqueIdentifier) : FlowLogic(query).states.single() return subFlow(FinalityFlow( transaction = serviceHub.signInitialTransaction(TransactionBuilder(notary = notary).apply { diff --git a/docs/source/api-testing.rst b/docs/source/api-testing.rst index 286e15cb70..78bf5e828f 100644 --- a/docs/source/api-testing.rst +++ b/docs/source/api-testing.rst @@ -201,19 +201,22 @@ CorDapps specific to their role in the network. Running the network ^^^^^^^^^^^^^^^^^^^ +When using a ``MockNetwork``, you must be careful to ensure that all the nodes have processed all the relevant messages +before making assertions about the result of performing some action. For example, if you start a flow to update the ledger +but don't wait until all the nodes involved have processed all the resulting messages, your nodes' vaults may not be in +the state you expect. -Regular Corda nodes automatically process received messages. When using a ``MockNetwork`` with -``networkSendManuallyPumped`` set to ``false``, you must manually initiate the processing of received messages. - +When ``networkSendManuallyPumped`` is set to ``false``, you must manually initiate the processing of received messages. You manually process received messages as follows: -* ``StartedMockNode.pumpReceive`` to process a single message from the node's queue - -* ``MockNetwork.runNetwork`` to process all the messages in every node's queue. This may generate additional messages - that must in turn be processed - - * ``network.runNetwork(-1)`` (the default in Kotlin) will exchange messages until there are no further messages to +* ``StartedMockNode.pumpReceive()`` processes a single message from the node's queue +* ``MockNetwork.runNetwork()`` processes all the messages in every node's queue until there are no further messages to process + +When ``networkSendManuallyPumped`` is set to ``true``, nodes will automatically process the messages they receive. You +can block until all messages have been processed using ``MockNetwork.waitQuiescent()``. + +.. warning:: If ``threadPerNode`` is set to ``true``, ``networkSendManuallyPumped`` must also be set to ``true``. Running flows ^^^^^^^^^^^^^ diff --git a/docs/source/clientrpc.rst b/docs/source/clientrpc.rst index 9d1128ec83..4038456fde 100644 --- a/docs/source/clientrpc.rst +++ b/docs/source/clientrpc.rst @@ -1,27 +1,52 @@ -Client RPC -========== +.. highlight:: kotlin +.. raw:: html + + + + +Interacting with a node +======================= .. contents:: Overview -------- -Corda provides a client library that allows you to easily write clients in a JVM-compatible language to interact -with a running node. The library connects to the node using a message queue protocol and then provides a simple RPC -interface to interact with the node. You make calls on a Java object as normal, and the marshalling back and forth is -handled for you. +You should interact with your node using the `CordaRPCClient`_ library. This library that allows you to easily +write clients in a JVM-compatible language to interact with a running node. The library connects to the node using a +message queue protocol and then provides a simple RPC interface to interact with the node. You make calls on a JVM +object as normal, and the marshalling back and forth is handled for you. -The starting point for the client library is the `CordaRPCClient`_ class. `CordaRPCClient`_ provides a ``start`` method -that returns a `CordaRPCConnection`_. A `CordaRPCConnection`_ allows you to access an implementation of the -`CordaRPCOps`_ interface with ``proxy`` in Kotlin or ``getProxy()`` in Java. The observables that are returned by RPC -operations can be subscribed to in order to receive an ongoing stream of updates from the node. More detail on this -functionality is provided in the docs for the ``proxy`` method. +.. warning:: The built-in Corda webserver is deprecated and unsuitable for production use. If you want to interact with + your node via HTTP, you will need to stand up your own webserver, then create an RPC connection between your node + and this webserver using the `CordaRPCClient`_ library. You can find an example of how to do this + `here `_. + +Connecting to a node via RPC +---------------------------- +`CordaRPCClient`_ provides a ``start`` method that takes the node's RPC address and returns a `CordaRPCConnection`_. +`CordaRPCConnection`_ provides a ``proxy`` method that takes an RPC username and password and returns a `CordaRPCOps`_ +object that you can use to interact with the node. + +Here is an example of using `CordaRPCClient`_ to connect to a node and log the current time on its internal clock: + +.. container:: codeset + + .. literalinclude:: example-code/src/main/kotlin/net/corda/docs/ClientRpcExample.kt + :language: kotlin + :start-after: START 1 + :end-before: END 1 + + .. literalinclude:: example-code/src/main/java/net/corda/docs/ClientRpcExampleJava.java + :language: java + :start-after: START 1 + :end-before: END 1 .. warning:: The returned `CordaRPCConnection`_ is somewhat expensive to create and consumes a small amount of server side resources. When you're done with it, call ``close`` on it. Alternatively you may use the ``use`` method on `CordaRPCClient`_ which cleans up automatically after the passed in lambda finishes. Don't create a new proxy for every call you make - reuse an existing one. -For a brief tutorial on using the RPC API, see :doc:`tutorial-clientrpc-api`. +For further information on using the RPC API, see :doc:`tutorial-clientrpc-api`. RPC permissions --------------- @@ -276,7 +301,7 @@ will be freed automatically. is non-deterministic. .. note:: Observables can only be used as return arguments of an RPC call. It is not currently possible to pass -Observables as parameters to the RPC methods. + Observables as parameters to the RPC methods. Futures ------- @@ -311,7 +336,7 @@ This does not expose internal information to clients, strengthening privacy and Connection management --------------------- -It is possible to not be able to connect to the server on the first attempt. In that case, the ``CordaRPCCLient.start()`` +It is possible to not be able to connect to the server on the first attempt. In that case, the ``CordaRPCClient.start()`` method will throw an exception. The following code snippet is an example of how to write a simple retry mechanism for such situations: diff --git a/docs/source/corda-configuration-file.rst b/docs/source/corda-configuration-file.rst index 35fdfaad3f..9c74f64c5e 100644 --- a/docs/source/corda-configuration-file.rst +++ b/docs/source/corda-configuration-file.rst @@ -1,4 +1,4 @@ -Node configuration +Configuring a node ================== .. contents:: diff --git a/docs/source/cordapp-build-systems.rst b/docs/source/cordapp-build-systems.rst index 044a41d26b..e0ba046589 100644 --- a/docs/source/cordapp-build-systems.rst +++ b/docs/source/cordapp-build-systems.rst @@ -1,5 +1,5 @@ -Building a CorDapp -================== +Building and installing a CorDapp +================================= .. contents:: @@ -187,5 +187,5 @@ These files are loaded when a CorDapp context is created and so can change durin CorDapp configuration can be accessed from ``CordappContext::config`` whenever a ``CordappContext`` is available. -There is an example project that demonstrates in ``samples` called ``cordapp-configuration`` and API documentation in -`_. +There is an example project that demonstrates in ``samples`` called ``cordapp-configuration`` and API documentation in +``_. diff --git a/docs/source/cordapp-overview.rst b/docs/source/cordapp-overview.rst index 36c845036a..7c59ed6701 100644 --- a/docs/source/cordapp-overview.rst +++ b/docs/source/cordapp-overview.rst @@ -3,20 +3,38 @@ What is a CorDapp? CorDapps (Corda Distributed Applications) are distributed applications that run on the Corda platform. The goal of a CorDapp is to allow nodes to reach agreement on updates to the ledger. They achieve this goal by defining flows that -Corda node owners can invoke through RPC calls: +Corda node owners can invoke over RPC: .. image:: resources/node-diagram.png + :scale: 25% + :align: center -CorDapps are made up of the following key components: +CorDapp components +------------------ +CorDapps take the form of a set of JAR files containing class definitions written in Java and/or Kotlin. -* States, defining the facts over which agreement is reached (see :doc:`Key Concepts - States `) +These class definitions will commonly include the following elements: + +* Flows: Define a routine for the node to run, usually to update the ledger + (see :doc:`Key Concepts - Flows `). They subclass ``FlowLogic`` +* States: Define the facts over which agreement is reached (see :doc:`Key Concepts - States `). + They implement the ``ContractState`` interface * Contracts, defining what constitutes a valid ledger update (see - :doc:`Key Concepts - Contracts `) -* Services, providing long-lived utilities within the node -* Serialisation whitelists, restricting what types your node will receive off the wire + :doc:`Key Concepts - Contracts `). They implement the ``Contract`` interface +* Services, providing long-lived utilities within the node. They subclass ``SingletonSerializationToken`` +* Serialisation whitelists, restricting what types your node will receive off the wire. They implement the + ``SerializationWhitelist`` interface -Each CorDapp is installed at the level of the individual node, rather than on the network itself. For example, a node -owner may choose to install the Bond Trading CorDapp, with the following components: +But the CorDapp JAR can also include other class definitions. These may include: + +* APIs and static web content: These are served by Corda's built-in webserver. This webserver is not + production-ready, and should be used for testing purposes only +* Utility classes + +An example +---------- +Suppose a node owner wants their node to be able to trade bonds. They may choose to install a Bond Trading CorDapp with +the following components: * A ``BondState``, used to represent bonds as shared facts on the ledger * A ``BondContract``, used to govern which ledger updates involving ``BondState`` states are valid diff --git a/docs/source/example-code/src/main/java/net/corda/docs/ClientRpcExampleJava.java b/docs/source/example-code/src/main/java/net/corda/docs/ClientRpcExampleJava.java new file mode 100644 index 0000000000..2a7bdb4d9b --- /dev/null +++ b/docs/source/example-code/src/main/java/net/corda/docs/ClientRpcExampleJava.java @@ -0,0 +1,34 @@ +package net.corda.docs; + +// START 1 +import net.corda.client.rpc.CordaRPCClient; +import net.corda.client.rpc.CordaRPCConnection; +import net.corda.core.messaging.CordaRPCOps; +import net.corda.core.utilities.NetworkHostAndPort; +import org.apache.activemq.artemis.api.core.ActiveMQException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ExecutionException; + +class ExampleRpcClientJava { + private static final Logger logger = LoggerFactory.getLogger(ExampleRpcClient.class); + + public static void main(String[] args) throws ActiveMQException, InterruptedException, ExecutionException { + if (args.length != 3) { + throw new IllegalArgumentException("Usage: TemplateClient "); + } + final NetworkHostAndPort nodeAddress = NetworkHostAndPort.parse(args[0]); + String username = args[1]; + String password = args[2]; + + final CordaRPCClient client = new CordaRPCClient(nodeAddress); + final CordaRPCConnection connection = client.start(username, password); + final CordaRPCOps cordaRPCOperations = connection.getProxy(); + + logger.info(cordaRPCOperations.currentNodeTime().toString()); + + connection.notifyServerAndClose(); + } +} +// END 1 \ No newline at end of file diff --git a/docs/source/example-code/src/main/kotlin/net/corda/docs/ClientRpcExample.kt b/docs/source/example-code/src/main/kotlin/net/corda/docs/ClientRpcExample.kt new file mode 100644 index 0000000000..e41eb2ed18 --- /dev/null +++ b/docs/source/example-code/src/main/kotlin/net/corda/docs/ClientRpcExample.kt @@ -0,0 +1,31 @@ +@file:Suppress("unused") + +package net.corda.docs + +// START 1 +import net.corda.client.rpc.CordaRPCClient +import net.corda.core.utilities.NetworkHostAndPort.Companion.parse +import net.corda.core.utilities.loggerFor +import org.slf4j.Logger + +class ExampleRpcClient { + companion object { + val logger: Logger = loggerFor() + } + + fun main(args: Array) { + require(args.size == 3) { "Usage: TemplateClient " } + val nodeAddress = parse(args[0]) + val username = args[1] + val password = args[2] + + val client = CordaRPCClient(nodeAddress) + val connection = client.start(username, password) + val cordaRPCOperations = connection.proxy + + logger.info(cordaRPCOperations.currentNodeTime().toString()) + + connection.notifyServerAndClose() + } +} +// END 1 \ No newline at end of file diff --git a/docs/source/shell.rst b/docs/source/shell.rst index dc07eafb67..90fb188f3a 100644 --- a/docs/source/shell.rst +++ b/docs/source/shell.rst @@ -4,8 +4,8 @@ -Shell -===== +Node shell +========== .. contents:: diff --git a/docs/source/tutorial-clientrpc-api.rst b/docs/source/tutorial-clientrpc-api.rst index 19ef950e36..79fd3ebf6d 100644 --- a/docs/source/tutorial-clientrpc-api.rst +++ b/docs/source/tutorial-clientrpc-api.rst @@ -1,3 +1,9 @@ +.. highlight:: kotlin +.. raw:: html + + + + .. _graphstream: http://graphstream-project.org/ Using the client RPC API diff --git a/docs/source/writing-a-cordapp.rst b/docs/source/writing-a-cordapp.rst index bda4f2977a..02bca02c2b 100644 --- a/docs/source/writing-a-cordapp.rst +++ b/docs/source/writing-a-cordapp.rst @@ -1,58 +1,70 @@ -Writing a CorDapp +CorDapp structure ================= .. contents:: -Overview --------- -CorDapps can be written in either Java, Kotlin, or a combination of the two. Each CorDapp component takes the form -of a JVM class that subclasses or implements a Corda library type: - -* Flows subclass ``FlowLogic`` -* States implement ``ContractState`` -* Contracts implement ``Contract`` -* Services subclass ``SingletonSerializationToken`` -* Serialisation whitelists implement ``SerializationWhitelist`` - -Web content and RPC clients ---------------------------- -For testing purposes, CorDapps may also include: - -* **APIs and static web content**: These are served by Corda's built-in webserver. This webserver is not - production-ready, and should be used for testing purposes only - -* **RPC clients**: These are programs that automate the process of interacting with a node via RPC - -In production, a production-ready webserver should be used, and these files should be moved into a different module or -project so that they do not bloat the CorDapp at build time. - .. _cordapp-structure: -Structure and dependencies --------------------------- -You should base your project on the Java template (for CorDapps written in Java) or the Kotlin template (for CorDapps -written in Kotlin): +Modules +------- +The source code for a CorDapp is divided into one or more modules, each of which will be compiled into a separate JAR. +Together, these JARs represent a single CorDapp. Typically, a Cordapp contains all the classes required for it to be +used standalone. However, some Cordapps are only libraries for other Cordapps and cannot be run standalone. -* `Java Template CorDapp `_ -* `Kotlin Template CorDapp `_ +A common pattern is to have: -Please checkout the branch of the template that corresponds to the version of Corda you are using. For example, someone -building a CorDapp on Corda 3 should use the ``release-V3`` branch of the template. +* One module containing only the CorDapp's contracts and/or states, as well as any required dependencies +* A second module containing the remaining classes that depend on these contracts and/or states -The required dependencies are defined by the ``build.gradle`` file in the root directory of the template. +This is because each time a contract is used in a transaction, the entire JAR containing the contract's definition is +attached to the transaction. This is to ensure that the exact same contract and state definitions are used when +verifying this transaction at a later date. Because of this, you will want to keep this module, and therefore the +resulting JAR file, as small as possible to reduce the size of your transactions and keep your node performant. -The project should be split into two modules: +However, this two-module structure is not prescriptive: -* A ``cordapp-contracts-states`` module containing classes such as contracts and states that will be sent across the - wire as part of a flow -* A ``cordapp`` module containing the remaining classes +* A library CorDapp containing only contracts and states would only need a single module -Each module will be compiled into its own CorDapp. This minimises the size of the JAR that has to be sent across the -wire when nodes are agreeing ledger updates. +* In a CorDapp with multiple sets of contracts and states that **do not** depend on each other, each independent set of + contracts and states would go in a separate module to reduce transaction size + +* In a CorDapp with multiple sets of contracts and states that **do** depend on each other, either keep them in the + same module or create separate modules that depend on each other + +* The module containing the flows and other classes can be structured in any way because it is not attached to + transactions + +Template CorDapps +----------------- +You should base your project on one of the following templates: + +* `Java Template CorDapp `_ (for CorDapps written in Java) +* `Kotlin Template CorDapp `_ (for CorDapps written in Kotlin) + +Please use the branch of the template that corresponds to the major version of Corda you are using. For example, +someone building a CorDapp on Corda 3.2 should use the ``release-V3`` branch of the template. + +Build system +^^^^^^^^^^^^ + +The templates are built using Gradle. A Gradle wrapper is provided in the ``wrapper`` folder, and the dependencies are +defined in the ``build.gradle`` files. See :doc:`cordapp-build-systems` for more information. + +No templates are currently provided for Maven or other build systems. + +Modules +^^^^^^^ +The templates are split into two modules: + +* A ``cordapp-contracts-states`` module containing the contracts and states +* A ``cordapp`` module containing the remaining classes that depends on the ``cordapp-contracts-states`` module + +These modules will be compiled into two JARs - a ``cordapp-contracts-states`` JAR and a ``cordapp`` JAR - which +together represent the Template CorDapp. Module one - cordapp-contracts-states -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Here is the structure of the ``src`` directory for the ``cordapp-contracts-states`` module: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Here is the structure of the ``src`` directory for the ``cordapp-contracts-states`` module of the Java template: .. parsed-literal:: @@ -73,8 +85,8 @@ These are definitions for classes that we expect to have to send over the wire. CorDapp. Module two - cordapp -^^^^^^^^^^^^^^^^^^^^ -Here is the structure of the ``src`` directory for the ``cordapp`` module: +~~~~~~~~~~~~~~~~~~~~ +Here is the structure of the ``src`` directory for the ``cordapp`` module of the Java template: .. parsed-literal:: @@ -116,37 +128,27 @@ The ``src`` directory is structured as follows: Within ``main``, we have the following directories: -* ``resources/META-INF/services`` contains registries of the CorDapp's serialisation whitelists and web plugins -* ``resources/certificates`` contains dummy certificates for test purposes -* ``resources/templateWeb`` contains a dummy front-end -* ``java`` (or ``kotlin`` in the Kotlin template), which includes the source-code for our CorDapp +* ``java``, which contains the source-code for our CorDapp: -The source-code for our CorDapp breaks down as follows: + * ``TemplateFlow.java``, which contains a template ``FlowLogic`` subclass + * ``TemplateState.java``, which contains a template ``ContractState`` implementation + * ``TemplateContract.java``, which contains a template ``Contract`` implementation + * ``TemplateSerializationWhitelist.java``, which contains a template ``SerializationWhitelist`` implementation + * ``TemplateApi.java``, which contains a template API for the deprecated Corda webserver + * ``TemplateWebPlugin.java``, which registers the API and front-end for the deprecated Corda webserver + * ``TemplateClient.java``, which contains a template RPC client for interacting with our CorDapp -* ``TemplateFlow.java``, which contains a dummy ``FlowLogic`` subclass -* ``TemplateState.java``, which contains a dummy ``ContractState`` implementation -* ``TemplateContract.java``, which contains a dummy ``Contract`` implementation -* ``TemplateSerializationWhitelist.java``, which contains a dummy ``SerializationWhitelist`` implementation +* ``resources/META-INF/services``, which contains various registries: -In developing your CorDapp, you should start by modifying these classes to define the components of your CorDapp. A -single CorDapp can define multiple flows, states, and contracts. + * ``net.corda.core.serialization.SerializationWhitelist``, which registers the CorDapp's serialisation whitelists + * ``net.corda.webserver.services.WebServerPluginRegistry``, which registers the CorDapp's web plugins -The template also includes a web API and RPC client: +* ``resources/templateWeb``, which contains a template front-end -* ``TemplateApi.java`` -* ``TemplateClient.java`` -* ``TemplateWebPlugin.java`` +In a production CorDapp: -These are for testing purposes and would be removed in a production CorDapp. +* We would remove the files related to the deprecated Corda webserver (``TemplateApi.java``, + ``TemplateWebPlugin.java``, ``resources/templateWeb``, and ``net.corda.webserver.services.WebServerPluginRegistry``) + and replace them with a production-ready webserver -Resources ---------- -In writing a CorDapp, these pages may be particularly helpful: - -* :doc:`getting-set-up`, to set up your development environment. -* The :doc:`hello-world-introduction` tutorial to write your first CorDapp. -* :doc:`cordapp-build-systems` to build and run your CorDapp. -* The `API docs `_ to read about the API available in developing CorDapps. -* There is also a :doc:`cheat-sheet` recapping the key types. -* The :doc:`flow-cookbook` to see code examples of how to perform common flow tasks. -* `Sample CorDapps `_ showing various parts of Corda's functionality. \ No newline at end of file +* We would also move ``TemplateClient.java`` into a separate module so that it is not included in the CorDapp \ No newline at end of file diff --git a/finance/src/main/kotlin/net/corda/finance/contracts/GetBalances.kt b/finance/src/main/kotlin/net/corda/finance/contracts/GetBalances.kt index 80a6ad48d3..4bb93d60a6 100644 --- a/finance/src/main/kotlin/net/corda/finance/contracts/GetBalances.kt +++ b/finance/src/main/kotlin/net/corda/finance/contracts/GetBalances.kt @@ -31,7 +31,8 @@ private fun generateCashSumCriteria(currency: Currency): QueryCriteria { val sumCriteria = QueryCriteria.VaultCustomQueryCriteria(sum) val ccyIndex = builder { CashSchemaV1.PersistentCashState::currency.equal(currency.currencyCode) } - val ccyCriteria = QueryCriteria.VaultCustomQueryCriteria(ccyIndex) + // This query should only return cash states the calling node is a participant of (meaning they can be modified/spent). + val ccyCriteria = QueryCriteria.VaultCustomQueryCriteria(ccyIndex, isModifiable = Vault.StateModificationStatus.MODIFIABLE) return sumCriteria.and(ccyCriteria) } @@ -40,7 +41,8 @@ private fun generateCashSumsCriteria(): QueryCriteria { CashSchemaV1.PersistentCashState::pennies.sum(groupByColumns = listOf(CashSchemaV1.PersistentCashState::currency), orderBy = Sort.Direction.DESC) } - return QueryCriteria.VaultCustomQueryCriteria(sum) + // This query should only return cash states the calling node is a participant of (meaning they can be modified/spent). + return QueryCriteria.VaultCustomQueryCriteria(sum, isModifiable = Vault.StateModificationStatus.MODIFIABLE) } private fun rowsToAmount(currency: Currency, rows: Vault.Page>): Amount { diff --git a/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionH2Impl.kt b/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionH2Impl.kt index 97d6753ff6..caedf7154d 100644 --- a/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionH2Impl.kt +++ b/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionH2Impl.kt @@ -43,11 +43,14 @@ class CashSelectionH2Impl : AbstractCashSelection() { override fun executeQuery(connection: Connection, amount: Amount, lockId: UUID, notary: Party?, onlyFromIssuerParties: Set, withIssuerRefs: Set, withResultSet: (ResultSet) -> Boolean): Boolean { connection.createStatement().use { it.execute("CALL SET(@t, CAST(0 AS BIGINT));") } + // state_status = 0 -> UNCONSUMED. + // is_modifiable = 0 -> MODIFIABLE. val selectJoin = """ SELECT vs.transaction_id, vs.output_index, ccs.pennies, SET(@t, ifnull(@t,0)+ccs.pennies) total_pennies, vs.lock_id FROM vault_states AS vs, contract_cash_states AS ccs WHERE vs.transaction_id = ccs.transaction_id AND vs.output_index = ccs.output_index AND vs.state_status = 0 + AND vs.is_modifiable = 0 AND ccs.ccy_code = ? and @t < ? AND (vs.lock_id = ? OR vs.lock_id is null) """ + diff --git a/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionPostgreSQLImpl.kt b/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionPostgreSQLImpl.kt index 19b6ad192d..808938a476 100644 --- a/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionPostgreSQLImpl.kt +++ b/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionPostgreSQLImpl.kt @@ -14,7 +14,9 @@ import net.corda.core.contracts.Amount import net.corda.core.crypto.toStringShort import net.corda.core.identity.AbstractParty import net.corda.core.identity.Party -import net.corda.core.utilities.* +import net.corda.core.utilities.OpaqueBytes +import net.corda.core.utilities.contextLogger +import net.corda.core.utilities.debug import java.sql.Connection import java.sql.DatabaseMetaData import java.sql.ResultSet @@ -39,6 +41,8 @@ class CashSelectionPostgreSQLImpl : AbstractCashSelection() { // appear in the WHERE clause, hence restricting row selection and adjusting the returned total in the outer query. // 3) Currently (version 9.6), FOR UPDATE cannot be specified with window functions override fun executeQuery(connection: Connection, amount: Amount, lockId: UUID, notary: Party?, onlyFromIssuerParties: Set, withIssuerRefs: Set, withResultSet: (ResultSet) -> Boolean): Boolean { + // state_status = 0 -> UNCONSUMED. + // is_modifiable = 0 -> MODIFIABLE. val selectJoin = """SELECT nested.transaction_id, nested.output_index, nested.pennies, nested.total+nested.pennies as total_pennies, nested.lock_id FROM @@ -48,6 +52,7 @@ class CashSelectionPostgreSQLImpl : AbstractCashSelection() { FROM vault_states AS vs, contract_cash_states AS ccs WHERE vs.transaction_id = ccs.transaction_id AND vs.output_index = ccs.output_index AND vs.state_status = 0 + AND vs.is_modifiable = 0 AND ccs.ccy_code = ? AND (vs.lock_id = ? OR vs.lock_id is null) """ + diff --git a/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionSQLServerImpl.kt b/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionSQLServerImpl.kt index 6e3e76051e..10663d1a5e 100644 --- a/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionSQLServerImpl.kt +++ b/finance/src/main/kotlin/net/corda/finance/contracts/asset/cash/selection/CashSelectionSQLServerImpl.kt @@ -16,6 +16,7 @@ import net.corda.core.identity.AbstractParty import net.corda.core.identity.Party import net.corda.core.utilities.OpaqueBytes import net.corda.core.utilities.contextLogger +import net.corda.core.utilities.debug import java.sql.Connection import java.sql.DatabaseMetaData import java.sql.ResultSet @@ -37,40 +38,77 @@ class CashSelectionSQLServerImpl : AbstractCashSelection(maxRetries = 16, retryS override fun toString() = "${this::class.qualifiedName} for '$JDBC_DRIVER_NAME'" + // This is one MSSQL implementation of the query to select just enough cash states to meet the desired amount. + // We select the cash states with smaller amounts first so that as the result, we minimize the numbers of + // unspent cash states remaining in the vault. + // + // If there is not enough cash, the query will return an empty resultset, which should signal to the caller + // of an exception, since the desired amount is assumed to always > 0. + // NOTE: The other two implementations, H2 and PostgresSQL, behave differently in this case - they return + // all in the vault instead of nothing. That seems to give the caller an extra burden to verify total returned + // >= amount. + // In addition, extra data fetched results in unnecessary I/O. + // Nevertheless, if so desired, we can achieve the same by changing the last FROM clause to + // FROM CTE LEFT JOIN Boundary AS B ON 1 = 1 + // WHERE B.seqNo IS NULL OR CTE.seqNo <= B.seqNo + // + // Common Table Expression and Windowed functions help make the query more readable. + // Query plan does index scan on pennies_idx, which may be unavoidable due to the nature of the query. override fun executeQuery(connection: Connection, amount: Amount, lockId: UUID, notary: Party?, onlyFromIssuerParties: Set, withIssuerRefs: Set, withResultSet: (ResultSet) -> Boolean): Boolean { - - val selectJoin = """ - WITH row(transaction_id, output_index, pennies, total, lock_id) AS + val sb = StringBuilder() + // state_status = 0 -> UNCONSUMED. + // is_modifiable = 0 -> MODIFIABLE. + sb.append( """ + ;WITH CTE AS ( - SELECT vs.transaction_id, vs.output_index, ccs.pennies, - SUM(ccs.pennies) OVER (ORDER BY ccs.transaction_id, ccs.output_index RANGE UNBOUNDED PRECEDING), vs.lock_id - FROM contract_cash_states AS ccs, vault_states AS vs - WHERE vs.transaction_id = ccs.transaction_id AND vs.output_index = ccs.output_index - AND vs.state_status = 0 - AND ccs.ccy_code = ? - AND (vs.lock_id = ? OR vs.lock_id is null)"""+ - (if (notary != null) - " AND vs.notary_name = ?" else "") + - // mssql-server driver does not implement setArray(), so in the following way - // we explicitly unpack the parameters list - (if (onlyFromIssuerParties.isNotEmpty()) { - val repeats = generateSequence { "?" } - .take(onlyFromIssuerParties.size) - .joinToString (",") - " AND ccs.issuer_key_hash IN ($repeats)" - } else { "" }) + - (if (withIssuerRefs.isNotEmpty()) { - val repeats = generateSequence { "?" } - .take(withIssuerRefs.size) - .joinToString (",") - " AND ccs.issuer_ref IN ($repeats)" - } else { "" }) + - """) - SELECT row.transaction_id, row.output_index, row.pennies, row.total, row.lock_id - FROM row where row.total < ? + row.pennies""" - + SELECT + vs.transaction_id, + vs.output_index, + ccs.pennies, + vs.lock_id, + total_pennies = SUM(ccs.pennies) OVER (ORDER BY ccs.pennies), + seqNo = ROW_NUMBER() OVER (ORDER BY ccs.pennies) + FROM vault_states AS vs INNER JOIN contract_cash_states AS ccs + ON vs.transaction_id = ccs.transaction_id AND vs.output_index = ccs.output_index + WHERE + vs.state_status = 0 + vs.is_modifiable = 0 + AND ccs.ccy_code = ? + AND (vs.lock_id = ? OR vs.lock_id IS NULL) + """ + ) + if (notary != null) + sb.append(""" + AND vs.notary_name = ? + """) + if (onlyFromIssuerParties.isNotEmpty()) { + val repeats = generateSequence { "?" }.take(onlyFromIssuerParties.size).joinToString(",") + sb.append(""" + AND ccs.issuer_key_hash IN ($repeats) + """) + } + if (withIssuerRefs.isNotEmpty()) { + val repeats = generateSequence { "?" }.take(withIssuerRefs.size).joinToString(",") + sb.append(""" + AND ccs.issuer_ref IN ($repeats) + """) + } + sb.append( + """ + ), + Boundary AS + ( + SELECT TOP (1) * FROM CTE WHERE total_pennies >= ? ORDER BY seqNo + ) + SELECT CTE.transaction_id, CTE.output_index, CTE.pennies, CTE.total_pennies, CTE.lock_id + FROM CTE INNER JOIN Boundary AS B ON CTE.seqNo <= B.seqNo + ; + """ + ) + val selectJoin = sb.toString() + log.debug { selectJoin } // Use prepared statement for protection against SQL Injection connection.prepareStatement(selectJoin).use { statement -> var pIndex = 0 diff --git a/node/build.gradle b/node/build.gradle index e2fa641136..ef002f14ce 100644 --- a/node/build.gradle +++ b/node/build.gradle @@ -58,7 +58,7 @@ sourceSets { jib.container { mainClass = "net.corda.node.Corda" args = ['--log-to-console', '--no-local-shell', '--config-file=/config/node.conf'] - jvmFlags = ['-Xmx1g', "-javaagent:/app/libs/quasar-core-${quasar_version}.jar"] + jvmFlags = ['-Xmx1g', '-javaagent:/app/libs/quasar-core-' + "${quasar_version}" + '-jdk8.jar'] } // Use manual resource copying of log4j2.xml rather than source sets. diff --git a/node/src/integration-test/kotlin/net/corda/services/vault/VaultRestartTest.kt b/node/src/integration-test/kotlin/net/corda/services/vault/VaultRestartTest.kt new file mode 100644 index 0000000000..a7d5650f2e --- /dev/null +++ b/node/src/integration-test/kotlin/net/corda/services/vault/VaultRestartTest.kt @@ -0,0 +1,48 @@ +package net.corda.services.vault + +import net.corda.core.CordaRuntimeException +import net.corda.core.contracts.FungibleAsset +import net.corda.core.messaging.startFlow +import net.corda.core.messaging.vaultQueryBy +import net.corda.core.utilities.OpaqueBytes +import net.corda.core.utilities.getOrThrow +import net.corda.finance.DOLLARS +import net.corda.finance.contracts.asset.Cash +import net.corda.finance.flows.CashIssueFlow +import net.corda.testing.core.DUMMY_BANK_A_NAME +import net.corda.testing.driver.DriverParameters +import net.corda.testing.driver.OutOfProcess +import net.corda.testing.driver.driver +import org.assertj.core.api.Assertions +import org.junit.Test + +class VaultRestartTest { + + @Test + fun `restart and query vault after adding some cash states`() { + driver(DriverParameters(inMemoryDB = false, startNodesInProcess = false, + extraCordappPackagesToScan = listOf("net.corda.finance.contracts", "net.corda.finance.schemas"))) { + val node = startNode(providedName = DUMMY_BANK_A_NAME, customOverrides = mapOf("p2pAddress" to "localhost:30000")).getOrThrow() + + val expected = 500.DOLLARS + val ref = OpaqueBytes.of(0x01) + val notary = node.rpc.notaryIdentities().firstOrNull() ?: throw CordaRuntimeException("Missing notary") + val issueTx = node.rpc.startFlow(::CashIssueFlow, expected, ref, notary).returnValue.getOrThrow() + println("Issued transaction: $issueTx") + + // Query vault + Assertions.assertThat(node.rpc.vaultQueryBy().states).hasSize(1) + Assertions.assertThat(node.rpc.vaultQueryBy>().states).hasSize(1) + + // Restart the node and re-query the vault + println("Shutting down the node ...") + (node as OutOfProcess).process.destroyForcibly() + node.stop() + + println("Restarting the node ...") + val restartedNode = startNode(providedName = DUMMY_BANK_A_NAME, customOverrides = mapOf("p2pAddress" to "localhost:30000")).getOrThrow() + Assertions.assertThat(restartedNode.rpc.vaultQueryBy().states).hasSize(1) + Assertions.assertThat(restartedNode.rpc.vaultQueryBy>().states).hasSize(1) + } + } +} diff --git a/node/src/main/kotlin/net/corda/node/NodeArgsParser.kt b/node/src/main/kotlin/net/corda/node/NodeArgsParser.kt index 363ec93f33..ceffedeef7 100644 --- a/node/src/main/kotlin/net/corda/node/NodeArgsParser.kt +++ b/node/src/main/kotlin/net/corda/node/NodeArgsParser.kt @@ -80,7 +80,8 @@ class NodeArgsParser : AbstractArgsParser() { .normalize() .toAbsolutePath() - val configFile = baseDirectory / optionSet.valueOf(configFileArg) + val configFilePath = Paths.get(optionSet.valueOf(configFileArg)) + val configFile = if (configFilePath.isAbsolute) configFilePath else baseDirectory / configFilePath.toString() val loggingLevel = optionSet.valueOf(loggerLevel) val logToConsole = optionSet.has(logToConsoleArg) val isRegistration = optionSet.has(isRegistrationArg) diff --git a/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt b/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt index 9db8e05f91..157ef99bae 100644 --- a/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt +++ b/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt @@ -111,106 +111,108 @@ open class NodeStartup(val args: Array) { drawBanner(versionInfo) Node.printBasicNodeInfo(LOGS_CAN_BE_FOUND_IN_STRING, System.getProperty("log-path")) - val conf = try { - val (rawConfig, conf0Result) = loadConfigFile(cmdlineOptions) - if (cmdlineOptions.devMode) { - println("Config:\n${rawConfig.root().render(ConfigRenderOptions.defaults())}") - } - val conf0 = conf0Result.getOrThrow() - if (cmdlineOptions.bootstrapRaftCluster) { - if (conf0 is NodeConfigurationImpl) { - println("Bootstrapping raft cluster (starting up as seed node).") - // Ignore the configured clusterAddresses to make the node bootstrap a cluster instead of joining. - conf0.copy(notary = conf0.notary?.copy(raft = conf0.notary?.raft?.copy(clusterAddresses = emptyList()))) - } else { - println("bootstrap-raft-notaries flag not recognized, exiting...") - return false - } - } else { - conf0 - } - } catch (e: UnknownConfigurationKeysException) { - logger.error(e.message) - return false - } catch (e: ConfigException.IO) { - println(""" - Unable to load the node config file from '${cmdlineOptions.configFile}'. - Try experimenting with the --base-directory flag to change which directory the node - is looking in, or use the --config-file flag to specify it explicitly. - """.trimIndent()) - return false - } catch (e: Exception) { - logger.error("Unexpected error whilst reading node configuration", e) - return false - } - val errors = conf.validate() + val configuration = (attempt { loadConfiguration(cmdlineOptions) }.doOnException(handleConfigurationLoadingError(cmdlineOptions.configFile)) as? Try.Success)?.let(Try.Success::value) ?: return false + + val errors = configuration.validate() if (errors.isNotEmpty()) { - logger.error("Invalid node configuration. Errors where:${System.lineSeparator()}${errors.joinToString(System.lineSeparator())}") + logger.error("Invalid node configuration. Errors were:${System.lineSeparator()}${errors.joinToString(System.lineSeparator())}") return false } - try { - banJavaSerialisation(conf) - preNetworkRegistration(conf) - if (cmdlineOptions.nodeRegistrationOption != null) { - // Null checks for [compatibilityZoneURL], [rootTruststorePath] and [rootTruststorePassword] has been done in [CmdLineOptions.loadConfig] - registerWithNetwork(conf, versionInfo, cmdlineOptions.nodeRegistrationOption) - // At this point the node registration was successful. We can delete the marker file. - deleteNodeRegistrationMarker(cmdlineOptions.baseDirectory) - return true - } - logStartupInfo(versionInfo, cmdlineOptions, conf) - } catch (e: NodeRegistrationException) { - logger.warn("Node registration service is unavailable. Perhaps try to perform the initial registration again after a while.", e) - return false - } catch (e: Exception) { - logger.error("Exception during node registration", e) - return false + attempt { banJavaSerialisation(configuration) }.doOnException { error -> error.logAsUnexpected("Exception while configuring serialisation") } as? Try.Success ?: return false + + attempt { preNetworkRegistration(configuration) }.doOnException(handleRegistrationError) as? Try.Success ?: return false + + cmdlineOptions.nodeRegistrationOption?.let { + // Null checks for [compatibilityZoneURL], [rootTruststorePath] and [rootTruststorePassword] has been done in [CmdLineOptions.loadConfig] + attempt { registerWithNetwork(configuration, versionInfo, cmdlineOptions.nodeRegistrationOption) }.doOnException(handleRegistrationError) as? Try.Success ?: return false + + // At this point the node registration was successful. We can delete the marker file. + deleteNodeRegistrationMarker(cmdlineOptions.baseDirectory) + return true } - try { - cmdlineOptions.baseDirectory.createDirectories() - startNode(conf, versionInfo, startTime, cmdlineOptions) + logStartupInfo(versionInfo, cmdlineOptions, configuration) - } catch (e: DatabaseMigrationException) { - logger.error(e.message) - return false - } catch (e: MultipleCordappsForFlowException) { - logger.error(e.message) - return false - } catch (e: CouldNotCreateDataSourceException) { - logger.error(e.message, e.cause) - return false - } catch (e: CheckpointIncompatibleException) { - logger.error(e.message) - return false - } catch (e: AddressBindingException) { - logger.error(e.message) - return false - } catch (e: NetworkParametersReader.Error) { - logger.error(e.message) - return false - } catch (e: DatabaseIncompatibleException) { - e.message?.let { Node.printWarning(it) } - logger.error(e.message) - return false - } catch (e: Exception) { - if (e is Errors.NativeIoException && e.message?.contains("Address already in use") == true) { - logger.error("One of the ports required by the Corda node is already in use.") - return false - } - if (e.message?.startsWith("Unknown named curve:") == true) { - logger.error("Exception during node startup - ${e.message}. " + - "This is a known OpenJDK issue on some Linux distributions, please use OpenJDK from zulu.org or Oracle JDK.") - } else { - logger.error("Exception during node startup", e) - } - return false + return attempt { startNode(configuration, versionInfo, startTime, cmdlineOptions) }.doOnSuccess { logger.info("Node exiting successfully") }.doOnException(handleStartError).isSuccess + } + + private fun attempt(action: () -> RESULT): Try = Try.on(action) + + private fun Exception.isExpectedWhenStartingNode() = startNodeExpectedErrors.any { error -> error.isInstance(this) } + + private val startNodeExpectedErrors = setOf(DatabaseMigrationException::class, MultipleCordappsForFlowException::class, CheckpointIncompatibleException::class, AddressBindingException::class, NetworkParametersReader::class, DatabaseIncompatibleException::class) + + private fun Exception.logAsExpected(message: String? = this.message, print: (String?) -> Unit = logger::error) = print("$message [errorCode=${errorCode()}]") + + private fun Exception.logAsUnexpected(message: String? = this.message, error: Exception = this, print: (String?, Throwable) -> Unit = logger::error) = print("$message [errorCode=${errorCode()}]", error) + + private fun Exception.isOpenJdkKnownIssue() = message?.startsWith("Unknown named curve:") == true + + private fun Exception.errorCode(): String { + + val hash = staticLocationBasedHash() + return Integer.toOctalString(hash) + } + + private fun Throwable.staticLocationBasedHash(visited: Set = setOf(this)): Int { + + val cause = this.cause + return when { + cause != null && !visited.contains(cause) -> Objects.hash(this::class.java.name, stackTrace, cause.staticLocationBasedHash(visited + cause)) + else -> Objects.hash(this::class.java.name, stackTrace) } + } - logger.info("Node exiting successfully") - return true + private val handleRegistrationError = { error: Exception -> + when (error) { + is NodeRegistrationException -> error.logAsExpected("Node registration service is unavailable. Perhaps try to perform the initial registration again after a while.") + else -> error.logAsUnexpected("Exception during node registration") + } + } + + private val handleStartError = { error: Exception -> + when { + error.isExpectedWhenStartingNode() -> error.logAsExpected() + error is CouldNotCreateDataSourceException -> error.logAsUnexpected() + error is Errors.NativeIoException && error.message?.contains("Address already in use") == true -> error.logAsExpected("One of the ports required by the Corda node is already in use.") + error.isOpenJdkKnownIssue() -> error.logAsExpected("Exception during node startup - ${error.message}. This is a known OpenJDK issue on some Linux distributions, please use OpenJDK from zulu.org or Oracle JDK.") + else -> error.logAsUnexpected("Exception during node startup") + } + } + + private fun handleConfigurationLoadingError(configFile: Path) = { error: Exception -> + when (error) { + is UnknownConfigurationKeysException -> error.logAsExpected() + is ConfigException.IO -> error.logAsExpected(configFileNotFoundMessage(configFile), ::println) + else -> error.logAsUnexpected("Unexpected error whilst reading node configuration") + } + } + + private fun configFileNotFoundMessage(configFile: Path): String { + return """ + Unable to load the node config file from '$configFile'. + + Try setting the --base-directory flag to change which directory the node + is looking in, or use the --config-file flag to specify it explicitly. + """.trimIndent() + } + + private fun loadConfiguration(cmdlineOptions: CmdLineOptions): NodeConfiguration { + + val (rawConfig, configurationResult) = loadConfigFile(cmdlineOptions) + if (cmdlineOptions.devMode) { + println("Config:\n${rawConfig.root().render(ConfigRenderOptions.defaults())}") + } + val configuration = configurationResult.getOrThrow() + return if (cmdlineOptions.bootstrapRaftCluster) { + println("Bootstrapping raft cluster (starting up as seed node).") + // Ignore the configured clusterAddresses to make the node bootstrap a cluster instead of joining. + (configuration as NodeConfigurationImpl).copy(notary = configuration.notary?.copy(raft = configuration.notary?.raft?.copy(clusterAddresses = emptyList()))) + } else { + configuration + } } private fun checkRegistrationMode(): Boolean { @@ -252,7 +254,7 @@ open class NodeStartup(val args: Array) { marker.delete() } } catch (e: Exception) { - logger.warn("Could not delete the marker file that was created for `--initial-registration`.", e) + e.logAsUnexpected("Could not delete the marker file that was created for `--initial-registration`.", print = logger::warn) } } @@ -261,6 +263,8 @@ open class NodeStartup(val args: Array) { protected open fun createNode(conf: NodeConfiguration, versionInfo: VersionInfo): Node = Node(conf, versionInfo) protected open fun startNode(conf: NodeConfiguration, versionInfo: VersionInfo, startTime: Long, cmdlineOptions: CmdLineOptions) { + + cmdlineOptions.baseDirectory.createDirectories() val node = createNode(conf, versionInfo) if (cmdlineOptions.clearNetworkMapCache) { node.clearNetworkMapCache() diff --git a/node/src/main/kotlin/net/corda/node/services/statemachine/FlowStateMachineImpl.kt b/node/src/main/kotlin/net/corda/node/services/statemachine/FlowStateMachineImpl.kt index 4197630cac..a5d48cb57e 100644 --- a/node/src/main/kotlin/net/corda/node/services/statemachine/FlowStateMachineImpl.kt +++ b/node/src/main/kotlin/net/corda/node/services/statemachine/FlowStateMachineImpl.kt @@ -199,7 +199,9 @@ class FlowStateMachineImpl(override val id: StateMachineRunId, private fun checkDbTransaction(isPresent: Boolean) { if (isPresent) { - requireNotNull(contextTransactionOrNull) + requireNotNull(contextTransactionOrNull) { + "Transaction context is missing. This might happen if a suspendable method is not annotated with @Suspendable annotation." + } } else { require(contextTransactionOrNull == null) } diff --git a/node/src/main/kotlin/net/corda/node/services/vault/HibernateQueryCriteriaParser.kt b/node/src/main/kotlin/net/corda/node/services/vault/HibernateQueryCriteriaParser.kt index fe21f9f003..10e2a9d521 100644 --- a/node/src/main/kotlin/net/corda/node/services/vault/HibernateQueryCriteriaParser.kt +++ b/node/src/main/kotlin/net/corda/node/services/vault/HibernateQueryCriteriaParser.kt @@ -496,6 +496,20 @@ class HibernateQueryCriteriaParser(val contractStateType: Class(VaultSchemaV1.VaultStates::isModifiable.name), criteria.isModifiable)) + } + } else { + commonPredicates[predicateID] = criteriaBuilder.equal(vaultStates.get(VaultSchemaV1.VaultStates::isModifiable.name), criteria.isModifiable) + } + } + // contract state types val contractStateTypes = deriveContractStateTypes(criteria.contractStateTypes) if (contractStateTypes.isNotEmpty()) { diff --git a/node/src/main/kotlin/net/corda/node/services/vault/NodeVaultService.kt b/node/src/main/kotlin/net/corda/node/services/vault/NodeVaultService.kt index c665e4c51c..4316c4048e 100644 --- a/node/src/main/kotlin/net/corda/node/services/vault/NodeVaultService.kt +++ b/node/src/main/kotlin/net/corda/node/services/vault/NodeVaultService.kt @@ -147,16 +147,39 @@ class NodeVaultService( FlowStateMachineImpl.currentStateMachine()?.hasSoftLockedStates = true log.trace { "Reserving soft lock for flow id $uuid and state ${stateAndRef.key}" } } - val state = VaultSchemaV1.VaultStates( + val stateOnly = stateAndRef.value.state.data + // TODO: Optimise this. + // + // For EVERY state to be committed to the vault, this checks whether it is spendable by the recording + // node. The behaviour is as follows: + // + // 1) All vault updates marked as MODIFIABLE will, of, course all have isModifiable = true. + // 2) For ALL_VISIBLE updates, those which are not modifiable will have isModifiable = false. + // + // This is useful when it comes to querying for fungible states, when we do not want non-modifiable states + // included in the result. + // + // The same functionality could be obtained by passing in a list of participants to the vault query, + // however this: + // + // * requires a join on the participants table which results in slow queries + // * states may flip from being non-modifiable to modifiable + // * it's more complicated for CorDapp developers + // + // Adding a new column in the "VaultStates" table was considered the best approach. + val keys = stateOnly.participants.map { it.owningKey } + val isModifiable = isModifiable(stateOnly, keyManagementService.filterMyKeys(keys).toSet()) + val stateToAdd = VaultSchemaV1.VaultStates( notary = stateAndRef.value.state.notary, contractStateClassName = stateAndRef.value.state.data.javaClass.name, stateStatus = Vault.StateStatus.UNCONSUMED, recordedTime = now, + isModifiable = if (isModifiable) Vault.StateModificationStatus.MODIFIABLE else Vault.StateModificationStatus.NOT_MODIFIABLE, lockId = uuid, lockUpdateTime = if (uuid == null) null else now ) - state.stateRef = PersistentStateRef(stateAndRef.key) - session.save(state) + stateToAdd.stateRef = PersistentStateRef(stateAndRef.key) + session.save(stateToAdd) } if (consumedStateRefs.isNotEmpty()) { // We have to do this so that the session does not hold onto the prior version of the states status. i.e. @@ -211,7 +234,7 @@ class NodeVaultService( val ourNewStates = when (statesToRecord) { StatesToRecord.NONE -> throw AssertionError("Should not reach here") StatesToRecord.ONLY_RELEVANT -> tx.outputs.withIndex().filter { - isRelevant(it.value.data, keyManagementService.filterMyKeys(tx.outputs.flatMap { it.data.participants.map { it.owningKey } }).toSet()) + isModifiable(it.value.data, keyManagementService.filterMyKeys(tx.outputs.flatMap { it.data.participants.map { it.owningKey } }).toSet()) } StatesToRecord.ALL_VISIBLE -> tx.outputs.withIndex() }.map { @@ -244,7 +267,7 @@ class NodeVaultService( val myKeys by lazy { keyManagementService.filterMyKeys(ltx.outputs.flatMap { it.data.participants.map { it.owningKey } }) } val (consumedStateAndRefs, producedStates) = ltx.inputs.zip(ltx.outputs).filter { (_, output) -> if (statesToRecord == StatesToRecord.ONLY_RELEVANT) { - isRelevant(output.data, myKeys.toSet()) + isModifiable(output.data, myKeys.toSet()) } else { true } @@ -444,12 +467,15 @@ class NodeVaultService( return emptyList() } - // Enrich QueryCriteria with additional default attributes (such as soft locks) + // Enrich QueryCriteria with additional default attributes (such as soft locks). + // We only want to return MODIFIABLE states here. val sortAttribute = SortAttribute.Standard(Sort.CommonStateAttribute.STATE_REF) val sorter = Sort(setOf(Sort.SortColumn(sortAttribute, Sort.Direction.ASC))) val enrichedCriteria = QueryCriteria.VaultQueryCriteria( contractStateTypes = setOf(contractStateType), - softLockingCondition = QueryCriteria.SoftLockingCondition(QueryCriteria.SoftLockingType.UNLOCKED_AND_SPECIFIED, listOf(lockId))) + softLockingCondition = QueryCriteria.SoftLockingCondition(QueryCriteria.SoftLockingType.UNLOCKED_AND_SPECIFIED, listOf(lockId)), + isModifiable = Vault.StateModificationStatus.MODIFIABLE + ) val results = queryBy(contractStateType, enrichedCriteria.and(eligibleStatesQuery), sorter) var claimedAmount = 0L @@ -472,9 +498,11 @@ class NodeVaultService( } @VisibleForTesting - internal fun isRelevant(state: ContractState, myKeys: Set): Boolean { + internal fun isModifiable(state: ContractState, myKeys: Set): Boolean { val keysToCheck = when (state) { - is OwnableState -> listOf(state.owner.owningKey) + // Sometimes developers forget to add the owning key to participants for OwnableStates. + // TODO: This logic should probably be moved to OwnableState so we can just do a simple intersection here. + is OwnableState -> (state.participants.map { it.owningKey } + state.owner.owningKey).toSet() else -> state.participants.map { it.owningKey } } return keysToCheck.any { it in myKeys } @@ -553,7 +581,8 @@ class NodeVaultService( vaultState.stateStatus, vaultState.notary, vaultState.lockId, - vaultState.lockUpdateTime)) + vaultState.lockUpdateTime, + vaultState.isModifiable)) } else { // TODO: improve typing of returned other results log.debug { "OtherResults: ${Arrays.toString(result.toArray())}" } @@ -610,7 +639,7 @@ class NodeVaultService( val contractTypes = deriveContractTypes(it) contractTypes.map { val contractStateType = contractStateTypeMappings.getOrPut(it.name) { mutableSetOf() } - contractStateType.add(it.name) + contractStateType.add(concreteType.name) } } } diff --git a/node/src/main/kotlin/net/corda/node/services/vault/VaultSchema.kt b/node/src/main/kotlin/net/corda/node/services/vault/VaultSchema.kt index 4b1d333c01..bf69d7567b 100644 --- a/node/src/main/kotlin/net/corda/node/services/vault/VaultSchema.kt +++ b/node/src/main/kotlin/net/corda/node/services/vault/VaultSchema.kt @@ -70,6 +70,10 @@ object VaultSchemaV1 : MappedSchema(schemaFamily = VaultSchema.javaClass, versio @Column(name = "lock_id", nullable = true) var lockId: String? = null, + /** Used to determine whether a state is modifiable by the recording node */ + @Column(name = "is_modifiable", nullable = false) + var isModifiable: Vault.StateModificationStatus, + /** refers to the last time a lock was taken (reserved) or updated (released, re-reserved) */ @Column(name = "lock_timestamp", nullable = true) var lockUpdateTime: Instant? = null diff --git a/node/src/test/kotlin/net/corda/node/NodeArgsParserTest.kt b/node/src/test/kotlin/net/corda/node/NodeArgsParserTest.kt index 3f015dde15..4d383c61b0 100644 --- a/node/src/test/kotlin/net/corda/node/NodeArgsParserTest.kt +++ b/node/src/test/kotlin/net/corda/node/NodeArgsParserTest.kt @@ -90,13 +90,6 @@ class NodeArgsParserTest { assertThat(cmdLineOptions.configFile).isEqualTo(configFile) } - @Test - fun `both base-directory and config-file`() { - assertThatExceptionOfType(IllegalArgumentException::class.java).isThrownBy { - parser.parse("--base-directory", "base", "--config-file", "conf") - }.withMessageContaining("base-directory").withMessageContaining("config-file") - } - @Test fun `base-directory without argument`() { assertThatExceptionOfType(OptionException::class.java).isThrownBy { diff --git a/node/src/test/kotlin/net/corda/node/services/vault/NodeVaultServiceTest.kt b/node/src/test/kotlin/net/corda/node/services/vault/NodeVaultServiceTest.kt index 3021545c6a..665dfe8235 100644 --- a/node/src/test/kotlin/net/corda/node/services/vault/NodeVaultServiceTest.kt +++ b/node/src/test/kotlin/net/corda/node/services/vault/NodeVaultServiceTest.kt @@ -38,6 +38,8 @@ import net.corda.finance.utils.sumCash import net.corda.node.services.api.IdentityServiceInternal import net.corda.node.services.api.WritableTransactionStorage import net.corda.nodeapi.internal.persistence.CordaPersistence +import net.corda.testing.contracts.DummyContract +import net.corda.testing.contracts.DummyState import net.corda.testing.core.* import net.corda.testing.internal.LogHelper import net.corda.testing.internal.rigorousMock @@ -61,7 +63,7 @@ import kotlin.test.assertTrue class NodeVaultServiceTest { private companion object { - val cordappPackages = listOf("net.corda.finance.contracts.asset", CashSchemaV1::class.packageName) + val cordappPackages = listOf("net.corda.finance.contracts.asset", CashSchemaV1::class.packageName, "net.corda.testing.contracts") val dummyCashIssuer = TestIdentity(CordaX500Name("Snake Oil Issuer", "London", "GB"), 10) val DUMMY_CASH_ISSUER = dummyCashIssuer.ref(1) val bankOfCorda = TestIdentity(BOC_NAME) @@ -536,17 +538,17 @@ class NodeVaultServiceTest { val amount = Amount(1000, Issued(BOC.ref(1), GBP)) val wellKnownCash = Cash.State(amount, identity.party) val myKeys = services.keyManagementService.filterMyKeys(listOf(wellKnownCash.owner.owningKey)) - assertTrue { service.isRelevant(wellKnownCash, myKeys.toSet()) } + assertTrue { service.isModifiable(wellKnownCash, myKeys.toSet()) } val anonymousIdentity = services.keyManagementService.freshKeyAndCert(identity, false) val anonymousCash = Cash.State(amount, anonymousIdentity.party) val anonymousKeys = services.keyManagementService.filterMyKeys(listOf(anonymousCash.owner.owningKey)) - assertTrue { service.isRelevant(anonymousCash, anonymousKeys.toSet()) } + assertTrue { service.isModifiable(anonymousCash, anonymousKeys.toSet()) } val thirdPartyIdentity = AnonymousParty(generateKeyPair().public) val thirdPartyCash = Cash.State(amount, thirdPartyIdentity) val thirdPartyKeys = services.keyManagementService.filterMyKeys(listOf(thirdPartyCash.owner.owningKey)) - assertFalse { service.isRelevant(thirdPartyCash, thirdPartyKeys.toSet()) } + assertFalse { service.isModifiable(thirdPartyCash, thirdPartyKeys.toSet()) } } // TODO: Unit test linear state relevancy checks @@ -737,4 +739,43 @@ class NodeVaultServiceTest { } assertThat(recordedStates).isEqualTo(coins.size) } + + @Test + fun `test state relevance criteria`() { + fun createTx(number: Int, vararg participants: Party): SignedTransaction { + return services.signInitialTransaction(TransactionBuilder(DUMMY_NOTARY).apply { + addOutputState(DummyState(number, participants.toList()), DummyContract.PROGRAM_ID) + addCommand(DummyCommandData, listOf(megaCorp.publicKey)) + }) + } + + fun List>.getNumbers() = map { it.state.data.magicNumber }.toSet() + + services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(1, megaCorp.party))) + services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(2, miniCorp.party))) + services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(3, miniCorp.party, megaCorp.party))) + services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(4, miniCorp.party))) + services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(5, bankOfCorda.party))) + services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(6, megaCorp.party, bankOfCorda.party))) + services.recordTransactions(StatesToRecord.NONE, listOf(createTx(7, bankOfCorda.party))) + + // Test one. + // StateModificationStatus is MODIFIABLE by default. This should return two states. + val resultOne = vaultService.queryBy().states.getNumbers() + assertEquals(setOf(1, 3, 4, 5, 6), resultOne) + + // Test two. + // StateModificationStatus set to NOT_MODIFIABLE. + val criteriaTwo = VaultQueryCriteria(isModifiable = Vault.StateModificationStatus.NOT_MODIFIABLE) + val resultTwo = vaultService.queryBy(criteriaTwo).states.getNumbers() + assertEquals(setOf(4, 5), resultTwo) + + // Test three. + // StateModificationStatus set to ALL. + val criteriaThree = VaultQueryCriteria(isModifiable = Vault.StateModificationStatus.MODIFIABLE) + val resultThree = vaultService.queryBy(criteriaThree).states.getNumbers() + assertEquals(setOf(1, 3, 6), resultThree) + + // We should never see 2 or 7. + } } diff --git a/settings.gradle b/settings.gradle index 23acca2c31..6fd80d0dc0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -41,12 +41,13 @@ include 'experimental:corda-utils' include 'experimental:rpc-worker' include 'jdk8u-deterministic' include 'test-common' +include 'test-cli' include 'test-utils' include 'smoke-test-utils' include 'node-driver' include 'perftestcordapp' // Avoid making 'testing' a project, and allow build.gradle files to refer to these by their simple names: -['test-common', 'test-utils', 'smoke-test-utils', 'node-driver'].each { +['test-common', 'test-utils', 'test-cli', 'smoke-test-utils', 'node-driver'].each { project(":$it").projectDir = new File("$settingsDir/testing/$it") } include 'testing:qa:behave:tools:rpc-proxy' diff --git a/testing/node-driver/src/main/kotlin/net/corda/testing/node/MockNetwork.kt b/testing/node-driver/src/main/kotlin/net/corda/testing/node/MockNetwork.kt index 9407f5b076..958a02f835 100644 --- a/testing/node-driver/src/main/kotlin/net/corda/testing/node/MockNetwork.kt +++ b/testing/node-driver/src/main/kotlin/net/corda/testing/node/MockNetwork.kt @@ -81,7 +81,7 @@ data class MockNodeParameters constructor( * Immutable builder for configuring a [MockNetwork]. Kotlin users can also use named parameters to the constructor * of [MockNetwork], which is more convenient. * - * @property networkSendManuallyPumped If true then messages will not be routed from sender to receiver until you use + * @property networkSendManuallyPumped If false then messages will not be routed from sender to receiver until you use * the [MockNetwork.runNetwork] method. This is useful for writing single-threaded unit test code that can examine the * state of the mock network before and after a message is sent, without races and without the receiving node immediately * sending a response. The default is false, so you must call runNetwork. @@ -290,7 +290,7 @@ inline fun > StartedMockNode.registerResponderFlow( * @property cordappPackages A [List] of cordapp packages to scan for any cordapp code, e.g. contract verification code, flows and services. * @property defaultParameters A [MockNetworkParameters] object which contains the same parameters as the constructor, provided * as a convenience for Java users. - * @property networkSendManuallyPumped If true then messages will not be routed from sender to receiver until you use + * @property networkSendManuallyPumped If false then messages will not be routed from sender to receiver until you use * the [MockNetwork.runNetwork] method. This is useful for writing single-threaded unit test code that can examine the * state of the mock network before and after a message is sent, without races and without the receiving node immediately * sending a response. The default is false, so you must call runNetwork. diff --git a/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt b/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt index 32a6e91abd..e0c7eefaf3 100644 --- a/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt +++ b/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt @@ -517,7 +517,8 @@ open class InternalMockNetwork(defaultParameters: MockNetworkParameters = MockNe */ @JvmOverloads fun runNetwork(rounds: Int = -1) { - check(!networkSendManuallyPumped) + check(!networkSendManuallyPumped) { "MockNetwork.runNetwork() should only be used when networkSendManuallyPumped == false. " + + "You can use MockNetwork.waitQuiescent() to wait for all the nodes to process all the messages on their queues instead." } fun pumpAll() = messagingNetwork.endpoints.map { it.pumpReceive(false) } if (rounds == -1) { diff --git a/testing/test-cli/build.gradle b/testing/test-cli/build.gradle new file mode 100644 index 0000000000..462499bf64 --- /dev/null +++ b/testing/test-cli/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'java' +apply plugin: 'kotlin' + +dependencies { + compile group: 'info.picocli', name: 'picocli', version: '3.0.1' + compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" + compile group: "com.fasterxml.jackson.dataformat", name: "jackson-dataformat-yaml", version: "2.9.0" + compile group: "com.fasterxml.jackson.core", name: "jackson-databind", version: "2.9.0" + compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.9.+" + compile "junit:junit:$junit_version" + +} +compileKotlin { + kotlinOptions { + languageVersion = "1.2" + } +} \ No newline at end of file diff --git a/testing/test-cli/src/main/kotlin/net/corda/testing/CliBackwardsCompatibleTest.kt b/testing/test-cli/src/main/kotlin/net/corda/testing/CliBackwardsCompatibleTest.kt new file mode 100644 index 0000000000..53b4b1b032 --- /dev/null +++ b/testing/test-cli/src/main/kotlin/net/corda/testing/CliBackwardsCompatibleTest.kt @@ -0,0 +1,19 @@ +package net.corda.testing + +import junit.framework.AssertionFailedError + +open class CliBackwardsCompatibleTest { + + + fun checkBackwardsCompatibility(clazz: Class<*>) { + val checker = CommandLineCompatibilityChecker() + val checkResults = checker.checkCommandLineIsBackwardsCompatible(clazz) + + if (checkResults.isNotEmpty()) { + val exceptionMessage= checkResults.map { it.message }.joinToString(separator = "\n") + throw AssertionFailedError("Command line is not backwards compatible:\n$exceptionMessage") + } + } + + +} \ No newline at end of file diff --git a/testing/test-cli/src/main/kotlin/net/corda/testing/GenerateCommandLineCompat.kt b/testing/test-cli/src/main/kotlin/net/corda/testing/GenerateCommandLineCompat.kt new file mode 100644 index 0000000000..e0141767c5 --- /dev/null +++ b/testing/test-cli/src/main/kotlin/net/corda/testing/GenerateCommandLineCompat.kt @@ -0,0 +1,188 @@ +package net.corda.testing + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import picocli.CommandLine +import java.io.InputStream +import java.util.* +import kotlin.collections.ArrayList + + +class CommandLineCompatibilityChecker { + + fun topoSort(commandLine: CommandLine): List { + val toVisit = Stack() + toVisit.push(commandLine) + val sorted: MutableList = ArrayList(); + while (toVisit.isNotEmpty()) { + val visiting = toVisit.pop() + sorted.add(visiting) + visiting.subcommands.values.sortedBy { it.commandName }.forEach { + toVisit.push(it) + } + } + return buildDescriptors(sorted) + } + + private fun buildDescriptors(result: MutableList): List { + return result.map { ::parseToDescription.invoke(it) } + } + + internal fun parseToDescription(it: CommandLine): CommandDescription { + val commandSpec = it.commandSpec + val options = commandSpec.options().filterNot { it.usageHelp() || it.versionHelp() } + .map { hit -> hit.names().map { it to hit } } + .flatMap { it } + .sortedBy { it.first } + .map { + val type = it.second.type() + ParameterDescription(it.first, type.componentType?.canonicalName + ?: type.canonicalName, it.second.required(), isMultiple(type), determineAcceptableOptions(type)) + } + + val positionals = commandSpec.positionalParameters().sortedBy { it.index() }.map { + val type = it.type() + ParameterDescription(it.index().toString(), type.componentType?.canonicalName + ?: type.canonicalName, it.required(), isMultiple(type)) + } + return CommandDescription(it.commandName, positionals, options) + } + + private fun determineAcceptableOptions(type: Class<*>?): List { + return if (type?.isEnum == true) { + type.enumConstants.map { it.toString() } + } else { + emptyList() + } + } + + fun isMultiple(clazz: Class<*>): Boolean { + return Iterable::class.java.isAssignableFrom(clazz) || Array::class.java.isAssignableFrom(clazz) + } + + fun printCommandDescription(commandLine: CommandLine) { + val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule() + val results = topoSort(commandLine) + println(objectMapper.writeValueAsString(results)) + } + + fun readCommandDescription(inputStream: InputStream): List { + val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule() + return objectMapper.readValue>(inputStream, object : TypeReference>() {}); + } + + fun checkAllCommandsArePresent(old: List, new: List): List { + val oldSet = old.map { it.commandName }.toSet() + val newSet = new.map { it.commandName }.toSet() + val newIsSuperSetOfOld = newSet.containsAll(oldSet) + return if (!newIsSuperSetOfOld) { + oldSet.filterNot { newSet.contains(it) }.map { + CommandsChangedError("SubCommand: $it has been removed from the CLI") + } + } else { + emptyList() + } + } + + fun checkAllOptionsArePresent(old: CommandDescription, new: CommandDescription): List { + if (old.commandName != new.commandName) { + throw IllegalArgumentException("Commands must match (${old.commandName} != ${new.commandName})") + } + val oldSet = old.params.map { it.parameterName }.toSet() + val newSet = new.params.map { it.parameterName }.toSet() + + val newIsSuperSetOfOld = newSet.containsAll(oldSet) + + return if (!newIsSuperSetOfOld) { + oldSet.filterNot { newSet.contains(it) }.map { + OptionsChangedError("Parameter: $it has been removed from subcommand: ${old.commandName}") + } + } else { + emptyList() + } + } + + fun checkAllPositionalCharactersArePresent(old: CommandDescription, new: CommandDescription): List { + if (old.commandName != new.commandName) { + throw IllegalArgumentException("Commands must match (${old.commandName} != ${new.commandName})") + } + val oldSet = old.positionalParams.sortedBy { it.parameterName }.toSet() + val newSet = new.positionalParams.sortedBy { it.parameterName}.toSet() + val newIsSuperSetOfOld = newSet.containsAll(oldSet) + return if (!newIsSuperSetOfOld) { + oldSet.filterNot { newSet.contains(it) }.map { + PositionalArgumentsChangedError("Positional Parameter [ ${it.parameterName} ] has been removed from subcommand: ${old.commandName}") + } + } else { + emptyList() + } + } + + fun checkAllParamsAreOfTheSameType(old: CommandDescription, new: CommandDescription): List { + + val oldMap = old.params.map { it.parameterName to it.parameterType }.toMap() + val newMap = new.params.map { it.parameterName to it.parameterType }.toMap() + + val changedTypes = oldMap.filter { newMap[it.key] != null && newMap[it.key] != it.value }.map { + TypesChangedError("Parameter [ ${it.key} has changed from type: ${it.value} to ${newMap[it.key]}") + } + val oldAcceptableTypes = old.params.map { it.parameterName to it.acceptableValues }.toMap() + val newAcceptableTypes = new.params.map { it.parameterName to it.acceptableValues }.toMap() + val potentiallyChanged = oldAcceptableTypes.filter { newAcceptableTypes[it.key] != null && newAcceptableTypes[it.key]!!.toSet() != it.value.toSet() } + val missingEnumErrors = potentiallyChanged.map { + val oldEnums = it.value + val newEnums = newAcceptableTypes[it.key]!! + if (!newEnums.containsAll(oldEnums)) { + val toPrint = oldEnums.toMutableSet() + toPrint.removeAll(newAcceptableTypes[it.key]!!) + EnumOptionsChangedError(it.key + " on command ${old.commandName} previously accepted: $oldEnums, and now is missing $toPrint}") + } else { + null + } + }.filterNotNull() + return changedTypes + missingEnumErrors + + } + + fun checkCommandLineIsBackwardsCompatible(commandLineToCheck: Class<*>): List { + val commandLineToCheckName = commandLineToCheck.canonicalName + val instance = commandLineToCheck.newInstance() + val resourceAsStream = this.javaClass.classLoader.getResourceAsStream("$commandLineToCheckName.yml") + ?: throw IllegalStateException("no Descriptor for $commandLineToCheckName found on classpath") + val old = readCommandDescription(resourceAsStream) + val new = topoSort(CommandLine(instance)) + return checkCommandLineIsBackwardsCompatible(old, new) + } + + + fun checkBackwardsCompatibility(old: CommandLine, new: CommandLine): List { + val topoSortOld= topoSort(old) + val topoSortNew= topoSort(new) + return checkCommandLineIsBackwardsCompatible(topoSortOld, topoSortNew) + } + + private fun checkCommandLineIsBackwardsCompatible(old: List, new: List): List { + val results = ArrayList() + results += checkAllCommandsArePresent(old, new) + for (oldCommand in old) { + new.find { it.commandName == oldCommand.commandName }?.let { newCommand -> + results += checkAllOptionsArePresent(oldCommand, newCommand) + results += checkAllParamsAreOfTheSameType(oldCommand, newCommand) + results += checkAllPositionalCharactersArePresent(oldCommand, newCommand) + } + } + + return results + } +} + +open class CliBackwardsCompatibilityValidationCheck(val message: String) +class OptionsChangedError(error: String) : CliBackwardsCompatibilityValidationCheck(error) +class TypesChangedError(error: String) : CliBackwardsCompatibilityValidationCheck(error) +class EnumOptionsChangedError(error: String) : CliBackwardsCompatibilityValidationCheck(error) +class CommandsChangedError(error: String) : CliBackwardsCompatibilityValidationCheck(error) +class PositionalArgumentsChangedError(error: String) : CliBackwardsCompatibilityValidationCheck(error) +data class CommandDescription(val commandName: String, val positionalParams: List, val params: List) +data class ParameterDescription(val parameterName: String, val parameterType: String, val required: Boolean, val multiParam: Boolean, val acceptableValues: List = emptyList()) \ No newline at end of file diff --git a/testing/test-cli/src/test/kotlin/net/corda/testing/CommandLineCompatibilityCheckerTest.kt b/testing/test-cli/src/test/kotlin/net/corda/testing/CommandLineCompatibilityCheckerTest.kt new file mode 100644 index 0000000000..bf3a3cf653 --- /dev/null +++ b/testing/test-cli/src/test/kotlin/net/corda/testing/CommandLineCompatibilityCheckerTest.kt @@ -0,0 +1,106 @@ +package net.corda.testing + +import org.hamcrest.CoreMatchers.* +import org.junit.Assert +import org.junit.Test +import picocli.CommandLine +import java.util.regex.Pattern + +class CommandLineCompatibilityCheckerTest { + + enum class AllOptions { + YES, NO, MAYBZ + } + + enum class BinaryOptions { + YES, NO + } + + + @Test + fun `should detect missing parameter`() { + val value1 = object { + @CommandLine.Option(names = arrayOf("-d", "--directory"), description = arrayOf("the directory to run in")) + var baseDirectory: String? = null + } + val value2 = object { + @CommandLine.Option(names = arrayOf("--directory"), description = arrayOf("the directory to run in")) + var baseDirectory: String? = null + } + val breaks = CommandLineCompatibilityChecker().checkBackwardsCompatibility(CommandLine(value1), CommandLine(value2)) + Assert.assertThat(breaks.size, `is`(1)) + Assert.assertThat(breaks.first(), `is`(instanceOf(OptionsChangedError::class.java))) + } + + + @Test + fun `should detect changes in positional parameters`() { + val value1 = object { + @CommandLine.Parameters(index = "0") + var baseDirectory: String? = null + @CommandLine.Parameters(index = "1") + var depth: Pattern? = null + } + val value2 = object { + @CommandLine.Parameters(index = "1") + var baseDirectory: String? = null + @CommandLine.Parameters(index = "0") + var depth: Int? = null + } + val breaks = CommandLineCompatibilityChecker().checkBackwardsCompatibility(CommandLine(value1), CommandLine(value2)) + Assert.assertThat(breaks.size, `is`(2)) + Assert.assertThat(breaks.first(), `is`(instanceOf(PositionalArgumentsChangedError::class.java))) + } + + @Test + fun `should detect removal of a subcommand`() { + @CommandLine.Command(subcommands = [ListCommand::class, StatusCommand::class]) + class Dummy + + @CommandLine.Command(subcommands = [ListCommand::class]) + class Dummy2 + + val breaks = CommandLineCompatibilityChecker().checkBackwardsCompatibility(CommandLine(Dummy()), CommandLine(Dummy2())) + Assert.assertThat(breaks.size, `is`(1)) + Assert.assertThat(breaks.first(), `is`(instanceOf(CommandsChangedError::class.java))) + } + + @Test + fun `should detect change of parameter type`() { + val value1 = object { + @CommandLine.Option(names = ["--directory"], description = ["the directory to run in"]) + var baseDirectory: String? = null + } + val value2 = object { + @CommandLine.Option(names = ["--directory"], description = ["the directory to run in"]) + var baseDirectory: Pattern? = null + } + + val breaks = CommandLineCompatibilityChecker().checkBackwardsCompatibility(CommandLine(value1), CommandLine(value2)) + Assert.assertThat(breaks.size, `is`(1)) + Assert.assertThat(breaks.first(), `is`(instanceOf(TypesChangedError::class.java))) + } + + @Test + fun `should detect change of enum options`() { + val value1 = object { + @CommandLine.Option(names = ["--directory"], description = ["the directory to run in"]) + var baseDirectory: AllOptions? = null + } + val value2 = object { + @CommandLine.Option(names = ["--directory"], description = ["the directory to run in"]) + var baseDirectory: BinaryOptions? = null + } + + val breaks = CommandLineCompatibilityChecker().checkBackwardsCompatibility(CommandLine(value1), CommandLine(value2)) + Assert.assertThat(breaks.filter { it is EnumOptionsChangedError }.size, `is`(1)) + Assert.assertThat(breaks.first { it is EnumOptionsChangedError }.message, containsString(AllOptions.MAYBZ.name)) + } + + @CommandLine.Command(name = "status") + class StatusCommand + + @CommandLine.Command(name = "ls") + class ListCommand + +} \ No newline at end of file diff --git a/testing/test-cli/src/test/resources/net.corda.testing.Dummy.yml b/testing/test-cli/src/test/resources/net.corda.testing.Dummy.yml new file mode 100644 index 0000000000..fa0f03a014 --- /dev/null +++ b/testing/test-cli/src/test/resources/net.corda.testing.Dummy.yml @@ -0,0 +1,77 @@ +- commandName: "
" + positionalParams: + - parameterName: "0" + parameterType: "java.net.InetAddress" + required: true + multiParam: false + acceptableValues: [] + - parameterName: "1" + parameterType: "int" + required: true + multiParam: false + acceptableValues: [] + params: + - parameterName: "--directory" + parameterType: "java.lang.String" + required: false + multiParam: false + acceptableValues: [] + - parameterName: "-d" + parameterType: "java.lang.String" + required: false + multiParam: false + acceptableValues: [] +- commandName: "status" + positionalParams: [] + params: + - parameterName: "--pattern" + parameterType: "java.lang.String" + required: false + multiParam: true + acceptableValues: [] + - parameterName: "--style" + parameterType: "net.corda.testing.DummyEnum" + required: false + multiParam: false + acceptableValues: + - "FULL" + - "DIR" + - "FILE" + - "DISK" + - parameterName: "-p" + parameterType: "java.lang.String" + required: false + multiParam: true + acceptableValues: [] + - parameterName: "-s" + parameterType: "net.corda.testing.DummyEnum" + required: false + multiParam: false + acceptableValues: + - "FULL" + - "DIR" + - "FILE" + - "DISK" +- commandName: "ls" + positionalParams: + - parameterName: "0" + parameterType: "java.lang.String" + required: true + multiParam: false + acceptableValues: [] + - parameterName: "1" + parameterType: "int" + required: true + multiParam: false + acceptableValues: [] + params: + - parameterName: "--depth" + parameterType: "java.lang.Integer" + required: false + multiParam: false + acceptableValues: [] + - parameterName: "-d" + parameterType: "java.lang.Integer" + required: false + multiParam: false + acceptableValues: [] \ No newline at end of file diff --git a/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt index 1ab0a95bb3..6245b4d387 100644 --- a/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt @@ -332,16 +332,16 @@ object InteractiveShell { for (ctor in clazz.constructors) { var paramNamesFromConstructor: List? = null fun getPrototype(): List { - val argTypes = ctor.parameterTypes.map { it.simpleName } + val argTypes = ctor.genericParameterTypes.map { it.typeName } return paramNamesFromConstructor!!.zip(argTypes).map { (name, type) -> "$name: $type" } } try { // Attempt construction with the given arguments. paramNamesFromConstructor = parser.paramNamesFromConstructor(ctor) - val args = parser.parseArguments(clazz.name, paramNamesFromConstructor.zip(ctor.parameterTypes), inputData) - if (args.size != ctor.parameterTypes.size) { - errors.add("${getPrototype()}: Wrong number of arguments (${args.size} provided, ${ctor.parameterTypes.size} needed)") + val args = parser.parseArguments(clazz.name, paramNamesFromConstructor.zip(ctor.genericParameterTypes), inputData) + if (args.size != ctor.genericParameterTypes.size) { + errors.add("${getPrototype()}: Wrong number of arguments (${args.size} provided, ${ctor.genericParameterTypes.size} needed)") continue } val flow = ctor.newInstance(*args) as FlowLogic<*> @@ -355,10 +355,10 @@ object InteractiveShell { } catch (e: StringToMethodCallParser.UnparseableCallException.TooManyParameters) { errors.add("${getPrototype()}: too many parameters") } catch (e: StringToMethodCallParser.UnparseableCallException.ReflectionDataMissing) { - val argTypes = ctor.parameterTypes.map { it.simpleName } + val argTypes = ctor.genericParameterTypes.map { it.typeName } errors.add("$argTypes: ") } catch (e: StringToMethodCallParser.UnparseableCallException) { - val argTypes = ctor.parameterTypes.map { it.simpleName } + val argTypes = ctor.genericParameterTypes.map { it.typeName } errors.add("$argTypes: ${e.message}") } } diff --git a/tools/shell/src/test/java/net/corda/tools/shell/InteractiveShellJavaTest.java b/tools/shell/src/test/java/net/corda/tools/shell/InteractiveShellJavaTest.java index 03e21ae9ff..7a66ac3881 100644 --- a/tools/shell/src/test/java/net/corda/tools/shell/InteractiveShellJavaTest.java +++ b/tools/shell/src/test/java/net/corda/tools/shell/InteractiveShellJavaTest.java @@ -1,10 +1,12 @@ package net.corda.tools.shell; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.google.common.collect.Lists; import kotlin.Pair; import net.corda.client.jackson.JacksonSupport; +import net.corda.client.jackson.internal.ToStringSerialize; import net.corda.core.contracts.Amount; import net.corda.core.crypto.SecureHash; import net.corda.core.flows.FlowException; @@ -26,18 +28,20 @@ import rx.Observable; import java.util.*; +import static java.util.stream.Collectors.toList; import static org.junit.Assert.assertEquals; public class InteractiveShellJavaTest { private static TestIdentity megaCorp = new TestIdentity(new CordaX500Name("MegaCorp", "London", "GB")); // should guarantee that FlowA will have synthetic method to access this field - private static String synthetic = "synth"; + private static final String synthetic = "synth"; abstract static class StringFlow extends FlowLogic { abstract String getA(); } + @SuppressWarnings("unused") public static class FlowA extends StringFlow { private String a; @@ -68,6 +72,18 @@ public class InteractiveShellJavaTest { this(party.getName().toString()); } + public FlowA(Integer b, Amount amount) { + this(String.format("%d %s", amount.getQuantity() + (b == null ? 0 : b), amount.getToken())); + } + + public FlowA(String[] b) { + this(String.join("+", b)); + } + + public FlowA(Amount[] amounts) { + this(String.join("++", Arrays.stream(amounts).map(Amount::toString).collect(toList()))); + } + @Nullable @Override public ProgressTracker getProgressTracker() { @@ -75,7 +91,7 @@ public class InteractiveShellJavaTest { } @Override - public String call() throws FlowException { + public String call() { return a; } @@ -106,9 +122,7 @@ public class InteractiveShellJavaTest { FlowSession session = initiateFlow(party); - Integer integer = session.receive(Integer.class).unwrap((i) -> { - return i; - }); + Integer integer = session.receive(Integer.class).unwrap((i) -> i); return integer.toString(); @@ -120,6 +134,24 @@ public class InteractiveShellJavaTest { } } + @ToStringSerialize + public static class UserValue { + private final String label; + + public UserValue(@JsonProperty("label") String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + + @Override + public String toString() { + return label; + } + } + private InMemoryIdentityService ids = new InMemoryIdentityService(Lists.newArrayList(megaCorp.getIdentity()), InternalTestConstantsKt.getDEV_ROOT_CA().getCertificate()); @SuppressWarnings("deprecation") @@ -159,9 +191,35 @@ public class InteractiveShellJavaTest { @Test public void flowStartWithNestedTypes() throws InteractiveShell.NoApplicableConstructor { check( - "pair: { first: $100.12, second: df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587 }", - "($100.12, df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587)", - FlowA.class); + "pair: { first: $100.12, second: df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587 }", + "(100.12 USD, DF489807F81C8C8829E509E1BCB92E6692B9DD9D624B7456435CB2F51DC82587)", + FlowA.class); + } + + @Test + public void flowStartWithUserAmount() throws InteractiveShell.NoApplicableConstructor { + check( + "b: 500, amount: { \"quantity\": 10001, \"token\":{ \"label\": \"of value\" } }", + "10501 of value", + FlowA.class); + } + + @Test + public void flowStartWithArrayType() throws InteractiveShell.NoApplicableConstructor { + check( + "b: [ One, Two, Three, Four ]", + "One+Two+Three+Four", + FlowA.class + ); + } + + @Test + public void flowStartWithArrayOfNestedType() throws InteractiveShell.NoApplicableConstructor { + check( + "amounts: [ { \"quantity\": 10, \"token\": { \"label\": \"(1)\" } }, { \"quantity\": 200, \"token\": { \"label\": \"(2)\" } } ]", + "10 (1)++200 (2)", + FlowA.class + ); } @Test(expected = InteractiveShell.NoApplicableConstructor.class) diff --git a/tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt b/tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt index 8daf7ad837..6884fa7371 100644 --- a/tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt +++ b/tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt @@ -10,8 +10,10 @@ package net.corda.tools.shell +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import net.corda.client.jackson.JacksonSupport +import net.corda.client.jackson.internal.ToStringSerialize import net.corda.core.contracts.Amount import net.corda.core.crypto.SecureHash import net.corda.core.flows.FlowLogic @@ -41,6 +43,9 @@ class InteractiveShellTest { constructor(amount: Amount) : this(amount.toString()) constructor(pair: Pair, SecureHash.SHA256>) : this(pair.toString()) constructor(party: Party) : this(party.name.toString()) + constructor(b: Int?, amount: Amount) : this("${(b ?: 0) + amount.quantity} ${amount.token}") + constructor(b: Array) : this(b.joinToString("+")) + constructor(amounts: Array>) : this(amounts.map(Amount::toString).joinToString("++")) override val progressTracker = ProgressTracker() override fun call() = a @@ -75,8 +80,26 @@ class InteractiveShellTest { @Test fun flowStartWithNestedTypes() = check( - "pair: { first: $100.12, second: df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587 }", - "($100.12, df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587)" + input = "pair: { first: $100.12, second: df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587 }", + expected = "(100.12 USD, DF489807F81C8C8829E509E1BCB92E6692B9DD9D624B7456435CB2F51DC82587)" + ) + + @Test + fun flowStartWithArrayType() = check( + input = "b: [ One, Two, Three, Four ]", + expected = "One+Two+Three+Four" + ) + + @Test + fun flowStartWithUserAmount() = check( + input = """b: 500, amount: { "quantity": 10001, "token":{ "label": "of value" } }""", + expected = "10501 of value" + ) + + @Test + fun flowStartWithArrayOfNestedTypes() = check( + input = """amounts: [ { "quantity": 10, "token": { "label": "(1)" } }, { "quantity": 200, "token": { "label": "(2)" } } ]""", + expected = "10 (1)++200 (2)" ) @Test(expected = InteractiveShell.NoApplicableConstructor::class) @@ -90,4 +113,9 @@ class InteractiveShellTest { @Test fun party() = check("party: \"${megaCorp.name}\"", megaCorp.name.toString()) + + @ToStringSerialize + data class UserValue(@JsonProperty("label") val label: String) { + override fun toString() = label + } } diff --git a/webserver/build.gradle b/webserver/build.gradle index 37d8d432fa..28553ab9e3 100644 --- a/webserver/build.gradle +++ b/webserver/build.gradle @@ -57,7 +57,7 @@ dependencies { compile "org.eclipse.jetty:jetty-continuation:${jetty_version}" compile "org.glassfish.jersey.core:jersey-server:$jersey_version" - compile "org.glassfish.jersey.containers:jersey-container-servlet-core:$jersey_version" + compile "org.glassfish.jersey.containers:jersey-container-servlet:$jersey_version" compile "org.glassfish.jersey.containers:jersey-container-jetty-http:$jersey_version" compile "org.glassfish.jersey.media:jersey-media-json-jackson:$jersey_version"