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 2820a7d15f..d551889fb1 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 @@ -413,10 +413,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/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/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 374009264c..62f69be446 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 @@ -95,10 +95,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)) }