From a1a006ae41ac77acf2c4ab01debcd1cb3198caed Mon Sep 17 00:00:00 2001 From: Oleg Smelov Date: Fri, 19 Jul 2024 17:46:07 +0400 Subject: [PATCH] fix encoding encode test messages/values validation dirty mode support dirty mode tests --- build.gradle | 6 +- .../exactpro/th2/codec/fixng/ByteBufUtil.kt | 6 +- .../exactpro/th2/codec/fixng/FixNgCodec.kt | 305 ++++++++++++++---- .../th2/codec/fixng/FixNgCodecFactory.kt | 7 +- .../th2/codec/fixng/FixNgCodecTest.kt | 247 +++++++++++++- 5 files changed, 496 insertions(+), 75 deletions(-) diff --git a/build.gradle b/build.gradle index b1e0b63..3fc1e57 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id "application" id "com.exactpro.th2.gradle.component" version "0.1.1" id "org.jetbrains.kotlin.jvm" version "${kotlin_version}" + id "org.jetbrains.kotlin.kapt" version "${kotlin_version}" } ext { @@ -71,7 +72,7 @@ repositories { dependencies { implementation "com.exactpro.th2:common:${commonVersion}" - implementation "com.exactpro.th2:codec:5.2.0-new-proto-+" + implementation "com.exactpro.th2:codec:5.5.0-dev" implementation ("com.exactpro.sf:sailfish-common:${sailfishVersion}") { exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml' // because of the vulnerability @@ -85,10 +86,11 @@ dependencies { compileOnly "com.google.auto.service:auto-service:1.1.1" annotationProcessor "com.google.auto.service:auto-service:1.1.1" - // kapt "com.google.auto.service:auto-service:1.1.1" + kapt "com.google.auto.service:auto-service:1.1.1" testImplementation "org.junit.jupiter:junit-jupiter:5.10.3" testImplementation "org.jetbrains.kotlin:kotlin-test-junit5:1.8.22" + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0' } test { diff --git a/src/main/kotlin/com/exactpro/th2/codec/fixng/ByteBufUtil.kt b/src/main/kotlin/com/exactpro/th2/codec/fixng/ByteBufUtil.kt index 207a4d7..ac06b5a 100644 --- a/src/main/kotlin/com/exactpro/th2/codec/fixng/ByteBufUtil.kt +++ b/src/main/kotlin/com/exactpro/th2/codec/fixng/ByteBufUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Exactpro (Exactpro Systems Limited) + * Copyright 2023-2024 Exactpro (Exactpro Systems Limited) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,7 +43,7 @@ private fun ByteBuf.printInt(sourceValue: Int, digits: Int = sourceValue.getDigi ensureWritable(digits) repeat(digits) { index -> - setByte(digits - index - 1, value % 10 + DIGIT_0) + setByte(digits - index - 1 + writerIndex(), value % 10 + DIGIT_0) value /= 10 } @@ -113,4 +113,4 @@ fun ByteBuf.writeChecksum() { while (isReadable) checksum += readByte() readerIndex(index) writeTag(10).printInt(checksum % 256, 3).writeByte(SOH_BYTE.toInt()) -} +} \ No newline at end of file diff --git a/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodec.kt b/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodec.kt index 6f0cebe..052c88b 100644 --- a/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodec.kt +++ b/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodec.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Exactpro (Exactpro Systems Limited) + * Copyright 2023-2024 Exactpro (Exactpro Systems Limited) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,31 +16,54 @@ package com.exactpro.th2.codec.fixng +import com.exactpro.sf.common.impl.messages.xml.configuration.JavaType import com.exactpro.sf.common.messages.structures.DictionaryConstants.FIELD_MESSAGE_TYPE +import com.exactpro.sf.common.messages.structures.IAttributeStructure import com.exactpro.sf.common.messages.structures.IDictionaryStructure import com.exactpro.sf.common.messages.structures.IFieldStructure import com.exactpro.sf.common.messages.structures.IMessageStructure import com.exactpro.sf.common.messages.structures.StructureUtils import com.exactpro.th2.codec.api.IPipelineCodec +import com.exactpro.th2.codec.api.IReportingContext import com.exactpro.th2.codec.fixng.FixNgCodecFactory.Companion.PROTOCOL import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.MessageGroup import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.ParsedMessage import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.RawMessage import io.netty.buffer.ByteBuf import io.netty.buffer.Unpooled +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField +import java.util.EnumMap import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.Message as CommonMessage -class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipelineCodec { +class FixNgCodec(dictionary: IDictionaryStructure, settings: FixNgCodecSettings) : IPipelineCodec { private val beginString = settings.beginString private val charset = settings.charset - private val messagesByType = messages.associateBy(Message::type) - private val messagesByName = messages.associateBy(Message::name) + private val fieldsEncode = convertToFields(dictionary.fields, true) + private val messagesByTypeForEncode: Map + private val messagesByTypeForDecode: Map + private val messagesByNameForEncode: Map + private val messagesByNameForDecode: Map + + init { + val messagesForEncode = dictionary.toMessages(true) + val messagesForDecode = dictionary.toMessages(false) + messagesByTypeForEncode = messagesForEncode.associateBy(Message::type) + messagesByTypeForDecode = messagesForDecode.associateBy(Message::type) + messagesByNameForEncode = messagesForEncode.associateBy(Message::name) + messagesByNameForDecode = messagesForDecode.associateBy(Message::name) + } - private val headerDef = messagesByName[HEADER] ?: error("Header is not defined in dictionary") - private val trailerDef = messagesByName[TRAILER] ?: error("Trailer is not defined in dictionary") + private val headerDef = messagesByNameForDecode[HEADER] ?: error("Header is not defined in dictionary") + private val trailerDef = messagesByNameForDecode[TRAILER] ?: error("Trailer is not defined in dictionary") - override fun encode(messageGroup: MessageGroup): MessageGroup { + override fun encode(messageGroup: MessageGroup, context: IReportingContext): MessageGroup { val messages = mutableListOf>() for (message in messageGroup.messages) { @@ -49,24 +72,27 @@ class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipel continue } - val messageDef = messagesByName[message.type] ?: error("Unknown message name: ${message.type}") + val isDirty = message.metadata[ENCODE_MODE_PROPERTY_NAME] == DIRTY_ENCODE_MODE + val messageDef = messagesByNameForEncode[message.type] ?: error("Unknown message name: ${message.type}") val messageFields = message.body as MutableMap - val headerFields = messageFields.remove(HEADER) as? Map<*, *> ?: mapOf() - val trailerFields = messageFields.remove(TRAILER) as? Map<*, *> ?: mapOf() + @Suppress("UNCHECKED_CAST") + val headerFields = messageFields.remove(HEADER) as? Map ?: mapOf() + @Suppress("UNCHECKED_CAST") + val trailerFields = messageFields.remove(TRAILER) as? Map ?: mapOf() val body = Unpooled.buffer(1024) val prefix = Unpooled.buffer(32) - val buffer = Unpooled.wrappedBuffer(prefix, body) - prefix.writeField(8, beginString, charset) - body.writeField(35, messageDef.type, charset) + prefix.writeField(TAG_BEGIN_STRING, beginString, charset) + body.writeField(TAG_MSG_TYPE, messageDef.type, charset) - headerDef.encode(headerFields, body) - messageDef.encode(messageFields, body) - trailerDef.encode(trailerFields, body) + headerDef.encode(headerFields, body, isDirty, fieldsEncode, context) + messageDef.encode(messageFields, body, isDirty, fieldsEncode, context) + trailerDef.encode(trailerFields, body, isDirty, fieldsEncode, context) - prefix.writeField(9, body.readableBytes(), charset) + prefix.writeField(TAG_BODY_LENGTH, body.readableBytes(), charset) + val buffer = Unpooled.wrappedBuffer(prefix, body) buffer.writeChecksum() messages += RawMessage( @@ -81,7 +107,7 @@ class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipel return MessageGroup(messages) } - override fun decode(messageGroup: MessageGroup): MessageGroup { + override fun decode(messageGroup: MessageGroup, context: IReportingContext): MessageGroup { val messages = mutableListOf>() for (message in messageGroup.messages) { @@ -92,15 +118,15 @@ class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipel val buffer = message.body - val beginString = buffer.readField(8, charset) { "Message starts with $it tag instead of BeginString (8)" } - val bodyLength = buffer.readField(9, charset) { "BeginString (8) is followed by $it tag instead of BodyLength (9)" } - val msgType = buffer.readField(35, charset) { "BodyLength (9) is followed by $it tag instead of MsgType (35)" } + val beginString = buffer.readField(TAG_BEGIN_STRING, charset) { "Message starts with $it tag instead of BeginString ($TAG_BEGIN_STRING)" } + val bodyLength = buffer.readField(TAG_BODY_LENGTH, charset) { "BeginString ($TAG_BEGIN_STRING) is followed by $it tag instead of BodyLength ($TAG_BODY_LENGTH)" } + val msgType = buffer.readField(TAG_MSG_TYPE, charset) { "BodyLength ($TAG_BODY_LENGTH) is followed by $it tag instead of MsgType ($TAG_MSG_TYPE)" } - val messageDef = messagesByType[msgType] ?: error("Unknown message type: $msgType") + val messageDef = messagesByTypeForDecode[msgType] ?: error("Unknown message type: $msgType") - val header = headerDef.decode(buffer) - val body = messageDef.decode(buffer) - val trailer = trailerDef.decode(buffer) + val header = headerDef.decode(buffer, context) + val body = messageDef.decode(buffer, context) + val trailer = trailerDef.decode(buffer, context) if (buffer.isReadable) error("Tag appears out of order: ${buffer.readTag()}") @@ -124,57 +150,175 @@ class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipel return MessageGroup(messages) } - private fun Field.decode(source: ByteBuf, target: MutableMap, value: String, tag: Int) { + private fun Field.decode(source: ByteBuf, target: MutableMap, value: String, tag: Int, context: IReportingContext) { val previous = when (this) { - is Primitive -> target.put(name, value) - is Group -> target.put(name, decode(source, value.toIntOrNull() ?: error("Invalid $name group counter ($tag) value: $value"))) + is Primitive -> { + val decodedValue = when (primitiveType) { + java.lang.String::class.java -> value + java.lang.Character::class.java -> { + check(value.length == 1) { "Wrong value" } + value[0] + } + java.lang.Integer::class.java -> value.toInt() + java.math.BigDecimal::class.java -> value.toBigDecimal() + java.lang.Long::class.java -> value.toLong() + java.lang.Short::class.java -> value.toShort() + java.lang.Byte::class.java -> value.toByte() + java.lang.Boolean::class.java -> value.toBoolean() + java.lang.Float::class.java -> value.toFloat() + java.lang.Double::class.java -> value.toDouble() + java.time.LocalDateTime::class.java -> LocalDateTime.parse(value, dateTimeFormatter) + java.time.LocalDate::class.java -> LocalDate.parse(value, dateFormatter) + java.time.LocalTime::class.java -> LocalTime.parse(value, timeFormatter) + else -> error("Unsupported type: ${this.primitiveType}") + } + + target.put(name, decodedValue) + } + is Group -> target.put(name, decode(source, value.toIntOrNull() ?: error("Invalid $name group counter ($tag) value: $value"), context)) else -> error("Unsupported field type: $this") } check(previous == null) { "Duplicate $name field ($tag) with value: $value (previous: $previous)" } } - private fun Message.decode(source: ByteBuf): MutableMap = mutableMapOf().also { map -> + private fun Message.decode(source: ByteBuf, context: IReportingContext): MutableMap = mutableMapOf().also { map -> source.forEachField(charset) { tag, value -> val field = get(tag) ?: return@forEachField false - field.decode(source, map, value, tag) + field.decode(source, map, value, tag, context) return@forEachField true } } - private fun Group.decode(source: ByteBuf, count: Int): List> = ArrayList>().also { list -> + private fun Group.decode(source: ByteBuf, count: Int, context: IReportingContext): List> = ArrayList>().also { list -> var map: MutableMap? = null source.forEachField(charset) { tag, value -> val field = get(tag) ?: return@forEachField false if (tag == delimiter) map = mutableMapOf().also(list::add) val group = checkNotNull(map) { "Field ${field.name} ($tag) appears before delimiter ($delimiter)" } - field.decode(source, group, value, tag) + field.decode(source, group, value, tag, context) return@forEachField true } check(list.size == count) { "Unexpected group $name count: ${list.size} (expected: $count)" } } - private fun FieldMap.encode(source: Map<*, *>, target: ByteBuf) = fields.forEach { (name, field) -> - val value = source[name] ?: when { - field.isRequired -> error("Missing required field: $name") - else -> return@forEach - } - + private fun encodeField(field: Field, value: Any, target: ByteBuf, isDirty: Boolean, dictionaryFields: Map, context: IReportingContext) { when { - field is Primitive -> if (field.tag != 8 && field.tag != 9 && field.tag != 10 && field.tag != 35) target.writeField(field.tag, value, charset) - field is Group && value is List<*> -> field.encode(value, target) + field is Primitive -> if ( + field.tag != TAG_BEGIN_STRING && + field.tag != TAG_BODY_LENGTH && + field.tag != TAG_CHECKSUM && + field.tag != TAG_MSG_TYPE + ) { + if (!isCompatibleType(value::class.java, field.primitiveType)) { + // TODO: 2.2 + if (isDirty) { + context.warning("Dirty mode WARNING: Wrong type value in field ${field.name}. Actual: ${value.javaClass} (value: $value). Expected ${field.primitiveType}") + } else { + error("Wrong type value in field ${field.name}. Actual: ${value.javaClass}. Expected ${field.primitiveType}") + } + } + + if (field.values.isNotEmpty() && !field.values.contains(value)) { + // TODO: 2.1 + if (isDirty) { + context.warning("Dirty mode WARNING: Wrong value in field ${field.name}. Actual: $value. Expected ${field.values}.") + } else { + error("Wrong value in field ${field.name}. Expected ${field.values}. Actual: $value") + } + } + + val stringValue = when (value) { + is java.lang.Boolean -> if (value.booleanValue()) "Y" else "N" + is LocalDateTime -> value.format(dateTimeFormatter) + is LocalDate -> value.format(dateFormatter) + is LocalTime -> value.format(timeFormatter) + else -> value.toString() + } + + if (stringValue.isEmpty()) { + if (isDirty) { + context.warning("Dirty mode WARNING: Empty value in the field '${field.name}'.") + } else { + error("Empty value in the field '${field.name}'") + } + } + + if (!stringValue.asSequence().all { it in ' ' .. '~' }) { + if (isDirty) { + context.warning("Dirty mode WARNING: Non printable characters in the field '${field.name}'. Value: $value") + } else { + error("Dirty mode WARNING: Non printable characters in the field '${field.name}'. Value: $value") + } + } + + target.writeField(field.tag, stringValue, charset) + } + + field is Group && value is List<*> -> field.encode(value, target, isDirty, dictionaryFields, context) + field is Message && value is Map<*,*> -> { + @Suppress("UNCHECKED_CAST") + val messageValue = value as Map + field.encode(messageValue, target, isDirty, dictionaryFields, context) + } else -> error("Unsupported value in ${field.name} field: $value") } } - private fun Group.encode(source: List<*>, target: ByteBuf) { + private fun FieldMap.encode(source: Map, target: ByteBuf, isDirty: Boolean, dictionaryFields: Map, context: IReportingContext) { + fields.forEach { (name, field) -> + val value = source[name] + if (value != null) { + encodeField(field, value, target, isDirty, dictionaryFields, context) + } else if (field.isRequired) { + if (isDirty) { + // TODO: C1 (1.5.1) + context.warning("Dirty mode WARNING: Required field missing. Field name: $name. Message body: $source") + } else { + error("Required field missing: $name. Message body: $source") + } + } + } + + source.filter { fields[it.key] == null }.forEach { (fieldName, value) -> + if (!isDirty) { + error("Unexpected field in message. Field name: $fieldName. Field value: $value. Message body: $source") + } + + val field = dictionaryFields[fieldName] + + if (field != null) { + // TODO: A1 (1.1.1) + context.warning("Dirty mode WARNING: Unexpected field in message. Field name: $fieldName. Field value: $value. Message body: $source") + encodeField(field, value ?: "", target, true, dictionaryFields, context) + } else { + val tag = fieldName.toIntOrNull() + if(tag != null && tag > 0) { + // TODO: A3 (1.1.3) + if (value is List<*>) { // TODO: do we need this check? + error("List value with unspecified name. tag = $tag") + } else { + context.warning("Dirty mode WARNING: Tag instead of field name. Field name: $fieldName. Field value: $value. Message body: $source") + target.writeField(tag, value, charset) + } + } else { + // TODO: A2 (1.1.2) + error("Field does not exist in dictionary. Field name: $fieldName. Field value: $value. Message body: $source") + } + } + } + } + + private fun Group.encode(source: List<*>, target: ByteBuf, isDirty: Boolean, dictionaryFields: Map, context: IReportingContext) { target.writeField(counter, source.size, charset) source.forEach { group -> check(group is Map<*, *>) { "Unsupported value in $name group: $group" } - encode(group, target) + @Suppress("UNCHECKED_CAST") + val groupMap = group as Map + encode(groupMap, target, isDirty, dictionaryFields, context) } } @@ -186,7 +330,9 @@ class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipel data class Primitive( override val isRequired: Boolean, override val name: String, - val tag: Int, + val primitiveType: Class<*>, + val values: Set, + val tag: Int ) : Field abstract class FieldMap { @@ -225,6 +371,47 @@ class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipel companion object { private const val HEADER = "header" private const val TRAILER = "trailer" + private const val ENCODE_MODE_PROPERTY_NAME = "encode-mode" + private const val DIRTY_ENCODE_MODE = "dirty" + + private const val TAG_BEGIN_STRING = 8 + private const val TAG_BODY_LENGTH = 9 + private const val TAG_CHECKSUM = 10 + private const val TAG_MSG_TYPE = 35 + + private val dateTimeFormatter = DateTimeFormatterBuilder() + .appendPattern("yyyyMMdd-HH:mm:ss") + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .toFormatter() + + private val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") + + private val timeFormatter = DateTimeFormatterBuilder() + .appendPattern("HH:mm:ss") + .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 9, true) + .toFormatter() + + private val javaTypeToClass = EnumMap>(JavaType::class.java).apply { + for (type in JavaType.values()) { + put(type, Class.forName(type.value())) + } + withDefault { error("Unsupported java type: $it") } + } + + private val typeSizes = mapOf( + java.lang.Byte::class.java to 1, + java.lang.Short::class.java to 2, + java.lang.Integer::class.java to 3, + java.lang.Long::class.java to 4, + BigDecimal:: class.java to 5 + ) + + private fun isCompatibleType(from: Class<*>, to: Class<*>): Boolean { + if (from == to) return true + val fromSize = typeSizes[from] ?: return false + val toSize = typeSizes[to] ?: return false + return fromSize < toSize + } private val IMessageStructure.entityType: String get() = StructureUtils.getAttributeValue(this, "entity_type") @@ -238,35 +425,45 @@ class FixNgCodec(messages: List, settings: FixNgCodecSettings) : IPipel private val IFieldStructure.tag: Int get() = StructureUtils.getAttributeValue(this, "tag") - private fun IFieldStructure.toPrimitive(): Primitive = Primitive(isRequired, name, tag) + private fun IFieldStructure.toPrimitive(): Primitive = Primitive( + isRequired, + name, + javaTypeToClass.getValue(javaType), + values.values.map { it.getCastValue() }.toSet(), + tag + ) - private fun IMessageStructure.toFields(): Map = linkedMapOf().apply { + private fun convertToFields(fields: Map, isForEncode: Boolean): Map = linkedMapOf().apply { fields.forEach { (name, field) -> when { field !is IMessageStructure -> this[name] = field.toPrimitive() - field.isGroup -> this[name] = field.toGroup() - field.isComponent -> this += field.toFields() + field.isGroup -> this[name] = field.toGroup(isForEncode) + field.isComponent -> if (isForEncode) { + this[name] = field.toMessage(true) + } else { + this += convertToFields(field.fields, false) + } } } } - private fun IMessageStructure.toMessage(): Message = Message( + private fun IMessageStructure.toMessage(isForEncode: Boolean): Message = Message( name = name, type = StructureUtils.getAttributeValue(this, FIELD_MESSAGE_TYPE) ?: name, - fields = toFields(), + fields = convertToFields(this.fields, isForEncode), isRequired = isRequired ) - private fun IMessageStructure.toGroup(): Group = Group( + private fun IMessageStructure.toGroup(isForEncode: Boolean): Group = Group( name = name, counter = tag, delimiter = fields.values.first().tag, - fields = toFields(), - isRequired = isRequired, + fields = convertToFields(this.fields, isForEncode), + isRequired = isRequired ) - fun IDictionaryStructure.toMessages(): List = messages.values + fun IDictionaryStructure.toMessages(isForEncode: Boolean): List = messages.values .filterNot { it.isGroup || it.isComponent } - .map { it.toMessage() } + .map { it.toMessage(isForEncode) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecFactory.kt b/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecFactory.kt index 5dc29b4..9d1ce36 100644 --- a/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecFactory.kt +++ b/src/main/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Exactpro (Exactpro Systems Limited) + * Copyright 2023-2024 Exactpro (Exactpro Systems Limited) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,14 +21,13 @@ import com.exactpro.th2.codec.api.IPipelineCodec import com.exactpro.th2.codec.api.IPipelineCodecContext import com.exactpro.th2.codec.api.IPipelineCodecFactory import com.exactpro.th2.codec.api.IPipelineCodecSettings -import com.exactpro.th2.codec.fixng.FixNgCodec.Companion.toMessages import com.google.auto.service.AutoService @AutoService(IPipelineCodecFactory::class) class FixNgCodecFactory : IPipelineCodecFactory { private lateinit var context: IPipelineCodecContext - @Deprecated("Please migrate to the protocols property") override val protocol: String = PROTOCOL + @Deprecated("Please migrate to the protocols property") override val protocols: Set = setOf(PROTOCOL) override val settingsClass: Class = FixNgCodecSettings::class.java override fun init(pipelineCodecContext: IPipelineCodecContext) { @@ -41,7 +40,7 @@ class FixNgCodecFactory : IPipelineCodecFactory { "settings is not an instance of ${FixNgCodecSettings::class.java}: ${settings?.let { it::class.java }}" } return FixNgCodec( - context[codecSettings.dictionary].use(XmlDictionaryStructureLoader()::load).toMessages(), + context[codecSettings.dictionary].use(XmlDictionaryStructureLoader()::load), requireNotNull(codecSettings as? FixNgCodecSettings) { "settings is not an instance of ${FixNgCodecSettings::class.java}: $codecSettings" } diff --git a/src/test/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecTest.kt b/src/test/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecTest.kt index b600de9..1edd9b8 100644 --- a/src/test/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecTest.kt +++ b/src/test/kotlin/com/exactpro/th2/codec/fixng/FixNgCodecTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Exactpro (Exactpro Systems Limited) + * Copyright 2023-2024 Exactpro (Exactpro Systems Limited) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,34 +13,57 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.exactpro.th2.codec.fixng import com.exactpro.sf.common.messages.structures.IDictionaryStructure import com.exactpro.sf.common.messages.structures.loaders.XmlDictionaryStructureLoader -import com.exactpro.th2.codec.fixng.FixNgCodec.Companion.toMessages -import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.MessageGroup -import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.ParsedMessage -import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.RawMessage +import com.exactpro.th2.codec.api.IReportingContext +import com.exactpro.th2.common.schema.message.impl.rabbitmq.transport.* +import com.exactpro.th2.codec.fixng.FixNgCodecFactory.Companion.PROTOCOL +import io.netty.buffer.CompositeByteBuf import io.netty.buffer.Unpooled import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.time.LocalDateTime import kotlin.test.assertEquals import kotlin.test.assertTrue - +import kotlin.test.assertFailsWith class FixNgCodecTest { + private val dictionary: IDictionaryStructure = FixNgCodecTest::class.java.classLoader + .getResourceAsStream("dictionary.xml") + .use(XmlDictionaryStructureLoader()::load) + + private val codec = FixNgCodec(dictionary, FixNgCodecSettings(dictionary = "")) + + private val reportingContext = object : IReportingContext { + private val _warnings: MutableList = ArrayList() + + val warnings: List + get() = _warnings + + override fun warning(message: String) { + _warnings.add(message) + } + + override fun warnings(messages: Iterable) { + _warnings.addAll(messages) + } + } - private val dictionary: IDictionaryStructure = - FixNgCodecTest::class.java.classLoader.getResourceAsStream("dictionary.xml") - .use(XmlDictionaryStructureLoader()::load) - private val codec = FixNgCodec(dictionary.toMessages(), FixNgCodecSettings(dictionary = "")) @Test fun `simple test decode encode`() { listOf( + // EXECUTION_REPORT RawMessage(body = Unpooled.wrappedBuffer("8=FIXT.1.1\u00019=156\u000135=8\u000134=10947\u000149=SENDER\u000152=20230419-10:36:07.415088\u000156=RECEIVER\u00011=test\u000111=zSuNbrBIZyVljs\u000138=500\u000139=0\u000140=A\u000141=zSuNbrBIZyVljs\u000144=1000\u000147=500\u000154=B\u000155=ABC\u000159=M\u000110=012\u0001".toByteArray())), + // ORDER_SINGLE RawMessage(body = Unpooled.wrappedBuffer("8=FIXT.1.1\u00019=133\u000135=D\u000134=11005\u000149=SENDER\u000152=20230419-10:36:07.415088\u000156=RECEIVER\u00011=test\u000111=UsVpSVQIcuqjQe\u000138=500\u000140=A\u000144=1000\u000147=500\u000154=B\u000155=ABC\u000159=M\u000110=000\u0001".toByteArray())), ).forEach { source -> - codec.decode(MessageGroup(mutableListOf(source))).also { group -> + codec.decode(MessageGroup(mutableListOf(source)), reportingContext).also { group -> assertEquals(1, group.messages.size) }.messages.first().also { decoded -> assertTrue(decoded is ParsedMessage) @@ -55,13 +78,213 @@ class FixNgCodecTest { } } + @Test + fun encode() { + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=295\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000117=495504662\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=0\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=test\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u000110=145\u0001", + fixMsg + ) + + val redecoded = codec.decode(encoded, reportingContext) + assertTrue { redecoded.messages.first() is ParsedMessage } + } + + @Test + fun `encode with addition field that exists in dictionary`() { + messageBody["CFICode"] = "12345" + + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=305\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000117=495504662\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=0\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=test\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u0001461=12345\u000110=097\u0001", + fixMsg + ) + + assertEquals(1, reportingContext.warnings.size) + assertTrue("Actual warning: ${reportingContext.warnings[0]}") { reportingContext.warnings[0].startsWith("Dirty mode WARNING: Unexpected field in message. Field name: CFICode. Field value: 12345.") } + } + + @Test + fun `encode with addition field that does not exists in dictionary`() { + messageBody["UNKNOWN_FIELD"] = "test_value" + + val exception = assertFailsWith { + codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + } + + assertTrue("Actual message: ${exception.message}") { exception.message?.startsWith("Field does not exist in dictionary. Field name: UNKNOWN_FIELD. Field value: test_value.") ?: false } + } + + @Test + fun `encode with addition field that contain tag instead of name`() { + messageBody["461"] = "12345" // 'CFICode' field + + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=305\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000117=495504662\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=0\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=test\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u0001461=12345\u000110=097\u0001", + fixMsg + ) + + assertEquals(1, reportingContext.warnings.size) + assertTrue { reportingContext.warnings[0].startsWith("Dirty mode WARNING: Tag instead of field name. Field name: 461. Field value: 12345.") } + } + + @Test + fun `encode with required field removed`() { + messageBody.remove("ExecID") + + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=282\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=0\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=test\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u000110=014\u0001", + fixMsg + ) + + assertEquals(1, reportingContext.warnings.size) + assertTrue { reportingContext.warnings[0].startsWith("Dirty mode WARNING: Required field missing. Field name: ExecID.") } + } + + @Test + fun `encode with wrong enum value`() { + messageBody["ExecType"]='X' + + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=295\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000117=495504662\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=X\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=test\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u000110=185\u0001", + fixMsg + ) + + assertEquals(1, reportingContext.warnings.size) + assertTrue("Actual warning: ${reportingContext.warnings[0]}") { reportingContext.warnings[0].startsWith("Dirty mode WARNING: Wrong value in field ExecType. Actual: X.") } + } + + @Test + fun `encode with wrong value type`() { + messageBody["LeavesQty"]="500" // String instead of BigDecimal + + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=295\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000117=495504662\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=0\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=test\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u000110=145\u0001", + fixMsg + ) + + assertEquals(1, reportingContext.warnings.size) + assertTrue("Actual warning: ${reportingContext.warnings[0]}") { reportingContext.warnings[0].startsWith("Dirty mode WARNING: Wrong type value in field LeavesQty. Actual: class java.lang.String (value: 500). Expected class java.math.BigDecimal") } + } + + @Test + fun `encode with empty value`() { + messageBody["Account"] = "" + + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=291\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000117=495504662\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=0\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u000110=205\u0001", + fixMsg + ) + + assertEquals(1, reportingContext.warnings.size) + assertTrue("Actual warning: ${reportingContext.warnings[0]}") { reportingContext.warnings[0].startsWith("Dirty mode WARNING: Empty value in the field 'Account'.") } + } + + @Test + fun `encode with non printable characters`() { + messageBody["Account"] = "test\taccount" + + val encoded = codec.encode(MessageGroup(listOf(parsedMessage)), reportingContext) + val body = encoded.messages.first().body as CompositeByteBuf + val fixMsg = body.toString(StandardCharsets.US_ASCII) + assertEquals( + "8=FIXT1.1\u00019=303\u000135=8\u000149=SENDER\u000156=RECEIVER\u000134=10947\u000152=20230419-10:36:07.415088\u000117=495504662\u000111=zSuNbrBIZyVljs\u000141=zSuNbrBIZyVljs\u000137=49415882\u0001150=0\u000139=0\u0001151=500\u000114=500\u000148=NWDR\u000122=8\u0001453=2\u0001448=NGALL1FX01\u0001447=D\u0001452=76\u0001448=0\u0001447=P\u0001452=3\u00011=test\taccount\u000140=A\u000159=0\u000154=B\u000155=ABC\u000138=500\u000144=1000\u000147=500\u000160=20180205-10:38:08.000008\u000110=125\u0001", + fixMsg + ) + + assertEquals(1, reportingContext.warnings.size) + assertTrue("Actual warning: ${reportingContext.warnings[0]}") { reportingContext.warnings[0].startsWith("Dirty mode WARNING: Non printable characters in the field 'Account'. Value: test\taccount") } + } + @Test fun `tag appears out of order`() { val tag = 999 assertThrows { - codec.decode(MessageGroup(mutableListOf(RawMessage(body = Unpooled.wrappedBuffer("8=FIXT.1.1\u00019=156\u000135=8\u000134=10947\u000149=SENDER\u000152=20230419-10:36:07.415088\u000156=RECEIVER\u0001$tag=500\u000110=012\u0001".toByteArray()))))) + codec.decode( + MessageGroup(mutableListOf( + RawMessage( + body = Unpooled.wrappedBuffer("8=FIXT.1.1\u00019=156\u000135=8\u000134=10947\u000149=SENDER\u000152=20230419-10:36:07.415088\u000156=RECEIVER\u0001$tag=500\u000110=012\u0001".toByteArray()) + ) + )), + reportingContext) }.also { ex -> assertEquals("Tag appears out of order: $tag", ex.message) } } + + private val parsedMessage = ParsedMessage( + MessageId("test_alias", Direction.OUTGOING, 0L, Instant.now(), emptyList()), + EventId("test_id", "test_book", "test_scope", Instant.now()), + "ExecutionReport", + mapOf("encode-mode" to "dirty"), + PROTOCOL, + mapOf( + "header" to mapOf( + "MsgSeqNum" to 10947, + "SenderCompID" to "SENDER", + "SendingTime" to LocalDateTime.parse("2023-04-19T10:36:07.415088"), + "TargetCompID" to "RECEIVER", + "BeginString" to "FIXT.1.1", + "BodyLength" to "156", + "MsgType" to "8" + ), + "ExecID" to "495504662", + "ClOrdID" to "zSuNbrBIZyVljs", + "OrigClOrdID" to "zSuNbrBIZyVljs", + "OrderID" to "49415882", + "ExecType" to '0', + "OrdStatus" to '0', + "LeavesQty" to 500, + "CumQty" to BigDecimal(500), + "SecurityID" to "NWDR", + "SecurityIDSource" to "8", + "TradingParty" to mapOf( + "NoPartyIDs" to listOf( + mapOf( + "PartyID" to "NGALL1FX01", + "PartyIDSource" to 'D', + "PartyRole" to 76 + ), + mapOf( + "PartyID" to "0", + "PartyIDSource" to 'P', + "PartyRole" to 3 + ) + ) + ), + "Account" to "test", + "OrdType" to 'A', + "TimeInForce" to '0', + "Side" to 'B', + "Symbol" to "ABC", + "OrderQty" to 500, + "Price" to 1000, + "Unknown" to "500", + "TransactTime" to LocalDateTime.parse("2018-02-05T10:38:08.000008"), + "trailer" to mapOf( + "CheckSum" to 55 + ) + ) + ) + + private val messageBody: MutableMap = parsedMessage.body as MutableMap } \ No newline at end of file