diff --git a/README.md b/README.md index c39f24b..4b784ec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# th2-conn-dirty-fix (0.0.7) +# th2-conn-dirty-fix (0.1.0) This microservice allows sending and receiving messages via FIX protocol @@ -58,14 +58,29 @@ This microservice allows sending and receiving messages via FIX protocol Mangler is configured by specifying a list of transformations which it will try to apply to outgoing messages. Each transformation has a list of conditions which message must meet for transformation actions to be applied. -Condition is basically a field value check: +Condition can be one of the following: -```yaml -tag: 35 -matches: (8|D) -``` +1. field selector: + + ```yaml + tag: 35 + matches: (8|D) + ``` + + Where `tag` is a field tag to match and `matches` is a regex pattern for the field value. + +2. group selector: -Where `tag` is a field tag to match and `matches` is a regex pattern for the field value. + ```yaml + group: group-name + contains: + - tag: 100 + matching: A + - tag: 101 + matching: C + ``` + + Where `group` is a name of a predefined group and `contains` is a list of field selectors Conditions are specified in `when` block of transformation definition: @@ -77,6 +92,19 @@ when: matches: SENDER(.*) ``` +Groups are defined in `context.groups` map of mangler configuration + +```yaml +context: + groups: + group-name: + counter: 99 + delimiter: 100 + tags: [ 101, 102, 103 ] +``` + +Where `counter` is a counter tag, `delimiter` is a delimiter tag and `tags` is a set of tags that could follow delimiter + Actions describe modifications which will be applied to a message. There are 4 types of actions: * set - sets value of an existing field to the specified value: @@ -84,7 +112,7 @@ Actions describe modifications which will be applied to a message. There are 4 t ```yaml set: tag: 1 - value: new account + to: new account ``` * add - adds new field before or after an existing field: @@ -92,7 +120,7 @@ Actions describe modifications which will be applied to a message. There are 4 t ```yaml add: tag: 15 - value: USD + equal: USD after: # or before tag: 58 matches: (.*) @@ -103,7 +131,7 @@ Actions describe modifications which will be applied to a message. There are 4 t ```yaml move: tag: 49 - matches: (.*) + matching: (.*) after: # or before tag: 56 matches: (.*) @@ -114,10 +142,10 @@ Actions describe modifications which will be applied to a message. There are 4 t ```yaml replace: tag: 64 - matches: (.*) + matching: (.*) with: tag: 63 - value: 1 + equal: 1 ``` * remove - removes an existing field: @@ -125,9 +153,22 @@ Actions describe modifications which will be applied to a message. There are 4 t ```yaml remove: tag: 110 - matches: (.*) + matching: (.*) ``` +Action scope could be limited to a certain group by specifying group selector in `in` field: + +```yaml +set: + tag: 100 + to: ABC +in: + group: group-name + where: + - tag: 101 + matches: C +``` + Actions are specified in `then` block of transformation definition: ```yaml @@ -158,6 +199,12 @@ Complete mangler configuration would look something like this: ```yaml mangler: + context: + groups: + NoPartyIDs: + counter: 453 + delimiter: 448 + tags: [ 447, 452 ] rules: - name: rule-1 transform: @@ -166,13 +213,17 @@ mangler: matches: FIXT.1.1 - tag: 35 matches: D + - group: NoPartyIDs + contains: + - tag: 448 + matching: ABC then: - set: tag: 1 - value: new account + to: new account - add: tag: 15 - value: USD + equal: USD after: tag: 58 matches: (.*) @@ -185,13 +236,13 @@ mangler: then: - replace: tag: 64 - matches: (.*) + matching: (.*) with: tag: 63 - value: 1 + equal: 1 - remove: tag: 110 - matches: (.*) + matching: (.*) update-checksum: false ``` @@ -253,24 +304,31 @@ spec: reconnectDelay": 5 disconnectRequestDelay: 5 mangler: + context: + groups: + NoPartyIDs: + counter: 453 + delimiter: 448 + tags: [ 447, 452 ] rules: - name: rule-1 transform: - when: - { tag: 8, matches: FIXT.1.1 } - { tag: 35, matches: D } + - { group: NoPartyIDs, contains: [ { tag: 448, matching: ABC } ] } then: - - set: { tag: 1, value: new account } - - add: { tag: 15, valueOneOf: ["USD", "EUR"] } + - set: { tag: 1, to: new account } + - add: { tag: 15, equal-one-of: [ "USD", "EUR" ] } after: { tag: 58, matches: (.*) } update-length: false - when: - { tag: 8, matches: FIXT.1.1 } - { tag: 35, matches: 8 } then: - - replace: { tag: 64, matches: (.*) } - with: { tag: 63, value: 1 } - - remove: { tag: 110, matches: (.*) } + - replace: { tag: 64, matching: (.*) } + with: { tag: 63, equal: 1 } + - remove: { tag: 110, matching: (.*) } update-checksum: false pins: - name: to_data_provider @@ -323,6 +381,10 @@ spec: # Changelog +## 0.1.0 + +* add basic support for repeating groups to mangler + ## 0.0.7 * wait for acceptor logout response on close diff --git a/gradle.properties b/gradle.properties index da6e0e5..31879de 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -release_version=0.0.7 +release_version=0.1.0 jackson_version=2.11.2 \ No newline at end of file diff --git a/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixByteBufUtil.kt b/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixByteBufUtil.kt index 7b6548c..1395a72 100644 --- a/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixByteBufUtil.kt +++ b/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixByteBufUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2022 Exactpro (Exactpro Systems Limited) + * Copyright 2022-2023 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,6 +16,7 @@ package com.exactpro.th2.conn.dirty.fix +import com.exactpro.th2.conn.dirty.fix.FixField.FixGroup import com.exactpro.th2.netty.bytebuf.util.EMPTY_STRING import com.exactpro.th2.netty.bytebuf.util.endsWith import com.exactpro.th2.netty.bytebuf.util.get @@ -43,6 +44,9 @@ fun ByteBuf.firstField(charset: Charset = UTF_8): FixField? = FixField.atOffset( @JvmOverloads fun ByteBuf.lastField(charset: Charset = UTF_8): FixField? = FixField.atOffset(this, writerIndex() - 1, charset) +val ByteBuf.fields: Iterable + get() = Iterable { iterator { forEachField { yield(it) } } } + @JvmOverloads inline fun ByteBuf.forEachField( charset: Charset = UTF_8, @@ -271,12 +275,23 @@ fun ByteBuf.updateLength(): ByteBuf { return replace(valueIndex, sohIndex, (endIndex - startIndex).toString()) } +fun Iterable.findGroup(counter: Int, delimiter: Int, tags: Iterable): FixGroup? { + val counter = find { it.tag == counter } ?: return null + val delimiter = counter.next()?.takeIf { it.tag == delimiter } ?: return null + return FixGroup(counter, delimiter, tags) +} + +interface FixElement { + fun previous(): FixElement? + fun next(): FixElement? +} + class FixField private constructor( private val buffer: ByteBuf, private var startIndex: Int, private var endIndex: Int, private val charset: Charset = UTF_8, -) { +) : FixElement { private var previous: FixField? = null private var next: FixField? = null @@ -412,7 +427,7 @@ class FixField private constructor( return "${tag ?: EMPTY_STRING}$SEP_CHAR${value ?: EMPTY_STRING}${SOH_CHAR}" } - fun next(): FixField? = next ?: when (endIndex) { + override fun next(): FixField? = next ?: when (endIndex) { buffer.writerIndex() -> null else -> atOffset( buffer, @@ -426,7 +441,7 @@ class FixField private constructor( } } - fun previous(): FixField? = previous ?: when (startIndex) { + override fun previous(): FixField? = previous ?: when (startIndex) { buffer.readerIndex() -> null else -> atOffset( buffer, @@ -493,4 +508,37 @@ class FixField private constructor( } } } + + class FixGroup( + val counter: FixField, + val delimiter: FixField, + val tags: Iterable, + ) : Iterable, FixElement { + override fun iterator(): Iterator = iterator { + var field: FixField? = delimiter + + while (field != null) { + if (field === delimiter || field.tag in tags) yield(field) else break + field = field.next() + } + } + + override fun next(): FixGroup? = find(delimiter.next()) { next() } + + override fun previous(): FixGroup? = find(delimiter.previous()) { previous() } + + private inline fun find(start: FixField?, next: FixField.() -> FixField?): FixGroup? { + val delimiter = delimiter.tag + var field = start + + while (field != null) { + val tag = field.tag + if (tag == delimiter) return FixGroup(counter, field, tags) + if (tag !in tags) break + field = field.next() + } + + return null + } + } } diff --git a/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixProtocolMangler.kt b/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixProtocolMangler.kt index e491866..fa46508 100644 --- a/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixProtocolMangler.kt +++ b/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/FixProtocolMangler.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2022 Exactpro (Exactpro Systems Limited) + * Copyright 2022-2023 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. @@ -89,9 +89,7 @@ class FixProtocolMangler(context: IManglerContext) : IMangler { if (rules.isEmpty()) return null val rule = rules.filter { rule -> - rule.transform.any { transform -> - transform.conditions.all { it.matches(message) } - } + rule.transform.any { it.conditions.all(message::contains) } }.randomOrNull() if (rule == null) { @@ -110,11 +108,18 @@ class FixProtocolManglerFactory : IManglerFactory { override fun create(context: IManglerContext) = FixProtocolMangler(context) } -class FixProtocolManglerSettings(val rules: List = emptyList()) : IManglerSettings +class FixProtocolManglerSettings( + val context: Context = Context(), + val rules: List = emptyList(), +) : IManglerSettings { + init { + rules.forEach { it.init(context) } + } +} private data class ActionRow( val corruptionType: String, val corruptedTag: Int, val corruptedValue: String?, val corruptionDescription: String, -) : IRow \ No newline at end of file +) : IRow diff --git a/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/MessageTransformer.kt b/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/MessageTransformer.kt index 43460f8..bd786a5 100644 --- a/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/MessageTransformer.kt +++ b/src/main/kotlin/com/exactpro/th2/conn/dirty/fix/MessageTransformer.kt @@ -16,11 +16,17 @@ package com.exactpro.th2.conn.dirty.fix +import com.exactpro.th2.conn.dirty.fix.FixField.FixGroup import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonSubTypes.Type +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id.DEDUCTION import io.netty.buffer.ByteBuf import mu.KotlinLogging import java.util.regex.Pattern +import kotlin.properties.Delegates.notNull typealias RuleID = String typealias Tag = Int @@ -35,7 +41,7 @@ object MessageTransformer { val results = mutableListOf().apply { for ((conditions, actions) in rule.transform) { - if (unconditionally || conditions.all { it.matches(message) }) { + if (unconditionally || conditions.all(message::contains)) { this += transform(message, actions) } } @@ -62,12 +68,19 @@ object MessageTransformer { } private fun transform(message: ByteBuf, actions: List) = sequence { - actions.forEach { action -> + for (action in actions) { + val context = when (val group = action.group) { + null -> message.fields + else -> group.find(message) ?: continue + } + action.set?.apply { val tag = singleTag val value = singleValue + val field = context.find { it.tag == tag } - if (message.setField(tag, value)) { + if (field != null) { + field.value = singleValue yield(ActionResult(tag, value, action)) } } @@ -76,41 +89,41 @@ object MessageTransformer { val tag = field.singleTag val value = field.singleValue - action.before?.find(message)?.let { next -> + action.before?.find(context)?.let { next -> next.insertPrevious(tag, value) yield(ActionResult(tag, value, action)) } - action.after?.find(message)?.let { previous -> + action.after?.find(context)?.let { previous -> previous.insertNext(tag, value) yield(ActionResult(tag, value, action)) } } - action.move?.find(message)?.let { field -> + action.move?.find(context)?.let { field -> val tag = checkNotNull(field.tag) { "Field tag for move was empty" } val value = field.value - action.before?.find(message)?.let { next -> + action.before?.find(context)?.let { next -> field.clear() next.insertPrevious(tag, value) yield(ActionResult(tag, value, action)) } - action.after?.find(message)?.let { previous -> + action.after?.find(context)?.let { previous -> previous.insertNext(tag, value) field.clear() yield(ActionResult(tag, value, action)) } } - action.remove?.find(message)?.let { field -> + action.remove?.find(context)?.let { field -> val tag = checkNotNull(field.tag) { "Field tag for remove was empty" } field.clear() yield(ActionResult(tag, null, action)) } - action.replace?.find(message)?.let { field -> + action.replace?.find(context)?.let { field -> val with = action.with!! val tag = with.singleTag val value = with.singleValue @@ -124,51 +137,111 @@ object MessageTransformer { } } +data class Group( + val counter: Int, + val delimiter: Int, + val tags: Set, +) { + init { + check(counter != delimiter) { "'counter' is equal to 'delimiter': $counter" } + check(counter !in tags) { "'tags' cannot contain 'counter' tag: $counter" } + check(delimiter !in tags) { "'tags' cannot contain 'delimiter' tag: $delimiter" } + } +} + +data class Context(val groups: Map = mapOf()) + +@JsonTypeInfo(use = DEDUCTION) +@JsonSubTypes(Type(FieldSelector::class), Type(GroupSelector::class)) +interface Selector { + fun init(context: Context) = Unit + fun find(message: ByteBuf): FixElement? + fun find(fields: Iterable): FixElement? +} + +fun ByteBuf.contains(selector: Selector): Boolean = selector.find(this) != null +fun Iterable.contains(selector: Selector): Boolean = selector.find(this) != null + data class FieldSelector( val tag: Tag?, - val tagOneOf: List?, - val matches: Pattern, -) { + @JsonAlias("one-of-tags", "tagOneOf") val tags: List?, + @JsonAlias("matching") val matches: Pattern, +) : Selector { init { - require((tag != null) xor !tagOneOf.isNullOrEmpty()) { "Either 'tag' or 'tagOneOf' must be specified" } + require((tag != null) xor !tags.isNullOrEmpty()) { "Either 'tag' or 'one-of-tags' must be specified" } } @JsonIgnore private val predicate = matches.asMatchPredicate() - fun matches(message: ByteBuf): Boolean = find(message) != null - - fun find(message: ByteBuf): FixField? = when { - tag != null -> message.findField { it.tag == tag && it.value?.run(predicate::test) ?: false } - else -> message.findFields { it.tag in tagOneOf!! && it.value?.run(predicate::test) ?: false }.randomOrNull() + override fun find(fields: Iterable): FixField? = when (tags) { + null -> fields.find { it.tag == tag && it.value?.run(predicate::test) ?: false } + else -> fields.filter { it.tag in tags && it.value?.run(predicate::test) ?: false }.randomOrNull() } + override fun find(message: ByteBuf): FixField? = find(message.fields) + override fun toString() = buildString { tag?.apply { append("tag $tag") } - tagOneOf?.apply { append("one of tags $tagOneOf") } + tags?.apply { append("one of tags $tags") } append(" ~= /$matches/") } } +data class GroupSelector( + val group: String, + @JsonAlias("contains", "where") val selectors: List, +) : Selector { + private var counter by notNull() + private var delimiter by notNull() + private lateinit var tags: Iterable + + init { + require(selectors.isNotEmpty()) { "group selector has no fields: $group" } + } + + override fun init(context: Context) { + val group = context.groups[group] ?: error("Unknown group: $group") + counter = group.counter + delimiter = group.delimiter + tags = group.tags + } + + override fun find(fields: Iterable): FixGroup? { + var group = fields.findGroup(counter, delimiter, tags) + + while (group != null) { + if (selectors.all(group::contains)) return group + group = group.next() + } + + return null + } + + override fun find(message: ByteBuf): FixGroup? = find(message.fields) + + override fun toString() = "group '$group' where ${selectors.joinToString(" && ")}" +} + data class FieldDefinition( val tag: Tag?, - val value: String?, - val tagOneOf: List?, - val valueOneOf: List? + @JsonAlias("to", "equal") val value: String?, + @JsonAlias("one-of-tags", "tagOneOf") val tags: List?, + @JsonAlias("valueOneOf", "to-one-of", "equal-one-of") val values: List?, ) { init { - require((tag != null) xor !tagOneOf.isNullOrEmpty()) { "Either 'tag' or 'tagOneOf' must be specified" } - require((value != null) xor !valueOneOf.isNullOrEmpty()) { "Either 'value' or 'valueOneOf' must be specified" } + require((tag != null) xor !tags.isNullOrEmpty()) { "Either 'tag' or 'one-of-tags' must be specified" } + require((value != null) xor !values.isNullOrEmpty()) { "'to/equal' and 'equal-one-of/to-one-of' are mutually exclusive" } } - @JsonIgnore val singleTag: Tag = tag ?: tagOneOf!!.random() - @JsonIgnore val singleValue: String = value ?: valueOneOf!!.random() + @JsonIgnore val singleTag: Tag = tag ?: tags!!.random() + @JsonIgnore val singleValue: String = value ?: values!!.random() override fun toString() = buildString { tag?.apply { append("tag $tag") } - tagOneOf?.apply { append("one of tags $tagOneOf") } + tags?.apply { append("one of $tags") } append(" = ") value?.apply { append("'$value'") } - valueOneOf?.apply { append("one of $valueOneOf") } + values?.apply { append("one of $values") } } } @@ -181,6 +254,7 @@ data class Action( val with: FieldDefinition? = null, val before: FieldSelector? = null, val after: FieldSelector? = null, + @JsonAlias("in") val group: GroupSelector? = null, ) { init { val operations = listOfNotNull(set, add, move, remove, replace) @@ -209,6 +283,15 @@ data class Action( } } + fun init(context: Context) { + move?.init(context) + replace?.init(context) + remove?.init(context) + before?.init(context) + after?.init(context) + group?.init(context) + } + override fun toString() = buildString { set?.apply { append("set $this") } add?.apply { append("add $this") } @@ -218,12 +301,12 @@ data class Action( with?.apply { append(" with $this") } before?.apply { append(" before $this") } after?.apply { append(" after $this") } + group?.apply { append(" on $this") } } } - data class Transform( - @JsonAlias("when") val conditions: List, + @JsonAlias("when") val conditions: List, @JsonAlias("then") val actions: List, @JsonAlias("update-length") val updateLength: Boolean = true, @JsonAlias("update-checksum") val updateChecksum: Boolean = true, @@ -233,6 +316,11 @@ data class Transform( require(actions.isNotEmpty()) { "Transformation must have at least one action" } } + fun init(context: Context) { + conditions.forEach { it.init(context) } + actions.forEach { it.init(context) } + } + override fun toString() = buildString { appendLine("when") conditions.forEach { appendLine(" $it") } @@ -249,6 +337,8 @@ data class Rule( require(transform.isNotEmpty()) { "Rule must have at least one transform" } } + fun init(context: Context) = transform.forEach { it.init(context) } + override fun toString() = buildString { appendLine("name: $name") appendLine("transforms: ") diff --git a/src/test/kotlin/com/exactpro/th2/conn/dirty/fix/TestMessageTransformer.kt b/src/test/kotlin/com/exactpro/th2/conn/dirty/fix/TestMessageTransformer.kt index 8732e90..2640c58 100644 --- a/src/test/kotlin/com/exactpro/th2/conn/dirty/fix/TestMessageTransformer.kt +++ b/src/test/kotlin/com/exactpro/th2/conn/dirty/fix/TestMessageTransformer.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2022 Exactpro (Exactpro Systems Limited) + * Copyright 2022-2023 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. @@ -22,13 +22,14 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.lang.System.lineSeparator +import kotlin.test.assertNotNull import kotlin.text.Charsets.UTF_8 class TestMessageTransformer { @Test fun `set field`() { val buffer = MESSAGE.toBuffer() val transform = set(49 to "abc") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals("set tag 49 = 'abc'", description) assertEquals("8=FIX.4.2|9=62|35=A|49=abc|56=CLIENT|34=177|52=20090107-18:15:16|98=0|108=30|10=138|", buffer.asString()) } @@ -36,7 +37,7 @@ class TestMessageTransformer { @Test fun `add field before`() { val buffer = MESSAGE.toBuffer() val transform = add(123 eq "abc") before (34 matches "177") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals("add tag 123 = 'abc' before tag 34 ~= /177/", description) assertEquals("8=FIX.4.2|9=73|35=A|49=SERVER|56=CLIENT|123=abc|34=177|52=20090107-18:15:16|98=0|108=30|10=055|", buffer.asString()) } @@ -44,7 +45,7 @@ class TestMessageTransformer { @Test fun `add field after`() { val buffer = MESSAGE.toBuffer() val transform = add(124 eq "cde") after (34 matches "177") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals("add tag 124 = 'cde' after tag 34 ~= /177/", description) assertEquals("8=FIX.4.2|9=73|35=A|49=SERVER|56=CLIENT|34=177|124=cde|52=20090107-18:15:16|98=0|108=30|10=062|", buffer.asString()) } @@ -52,7 +53,7 @@ class TestMessageTransformer { @Test fun `move field before`() { val buffer = MESSAGE.toBuffer() val transform = move(56 matching ".*") before (49 matches ".*") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals("move tag 56 ~= /.*/ before tag 49 ~= /.*/", description) assertEquals("8=FIX.4.2|9=65|35=A|56=CLIENT|49=SERVER|34=177|52=20090107-18:15:16|98=0|108=30|10=062|", buffer.asString()) } @@ -60,7 +61,7 @@ class TestMessageTransformer { @Test fun `move field after`() { val buffer = MESSAGE.toBuffer() val transform = move(49 matching ".*") after (56 matches ".*") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals("move tag 49 ~= /.*/ after tag 56 ~= /.*/", description) assertEquals("8=FIX.4.2|9=65|35=A|56=CLIENT|49=SERVER|34=177|52=20090107-18:15:16|98=0|108=30|10=062|", buffer.asString()) } @@ -68,7 +69,7 @@ class TestMessageTransformer { @Test fun `add field after random one of`() { val buffer = MESSAGE.toBuffer() val transform = add(124 oneOf listOf("cde", "cbe")) after (34 matches "177") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals(description, "add tag 124 = one of [cde, cbe] after tag 34 ~= /177/") val resultString = buffer.asString() assertTrue("8=FIX.4.2|9=73|35=A|49=SERVER|56=CLIENT|34=177|124=cde|52=20090107-18:15:16|98=0|108=30|10=062|" == resultString || @@ -79,7 +80,7 @@ class TestMessageTransformer { @Test fun `replace field`() { val buffer = MESSAGE.toBuffer() val transform = replace(98 matching "0") with (100 eq "1") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals("replace tag 98 ~= /0/ with tag 100 = '1'", description) assertEquals("8=FIX.4.2|9=66|35=A|49=SERVER|56=CLIENT|34=177|52=20090107-18:15:16|100=1|108=30|10=096|", buffer.asString()) } @@ -87,13 +88,38 @@ class TestMessageTransformer { @Test fun `remove field`() { val buffer = MESSAGE.toBuffer() val transform = remove(52 matching ".*") onlyIf (35 matches "A") - val description = MessageTransformer.transform(buffer, Rule("0", transform))!!.results.joinToString(lineSeparator()) { it.action.toString() } + val description = transform.applyTo(buffer) assertEquals("remove tag 52 ~= /.*/", description) assertEquals("8=FIX.4.2|9=44|35=A|49=SERVER|56=CLIENT|34=177|98=0|108=30|10=044|", buffer.asString()) } + @Test fun `set field in group`() { + val buffer = GROUP_MESSAGE.toBuffer() + val transform = set(110 to "abc") onGroup ("test" where (100 matches "d")) onlyIf (99 matches "2") + val description = transform.applyTo(buffer) + assertEquals("set tag 110 = 'abc' on group 'test' where tag 100 ~= /d/", description) + assertEquals("99=2|100=a|110=b|120=c|100=d|110=abc|120=f|", buffer.asString()) + } + + @Test fun `remove field in group`() { + val buffer = GROUP_MESSAGE.toBuffer() + val transform = remove(120 matching "c") onGroup ("test" where (110 matches "b")) onlyIf (99 matches "2") + val description = transform.applyTo(buffer) + assertEquals("remove tag 120 ~= /c/ on group 'test' where tag 110 ~= /b/", description) + assertEquals("99=2|100=a|110=b|100=d|110=e|120=f|", buffer.asString()) + } + + private fun List.applyTo(buffer: ByteBuf): String { + val transform = MessageTransformer.transform(buffer, Rule("test", this)) + assertNotNull(transform, "transformation yielded no results") + return transform.results.joinToString(lineSeparator()) { it.action.toString() } + } + companion object { private const val MESSAGE = "8=FIX.4.2|9=65|35=A|49=SERVER|56=CLIENT|34=177|52=20090107-18:15:16|98=0|108=30|10=062|" + private const val GROUP_MESSAGE = "99=2|100=a|110=b|120=c|100=d|110=e|120=f|" + private val CONTEXT = Context(groups = mapOf("test" to Group(99, 100, setOf(110, 120)))) + private fun String.toBuffer() = Unpooled.buffer().writeBytes(replace('|', SOH_CHAR).toByteArray(UTF_8)) private fun ByteBuf.asString() = toString(UTF_8).replace(SOH_CHAR, '|') private fun field(tag: Int, value: String) = FieldDefinition(tag, value, null, null) @@ -114,5 +140,7 @@ class TestMessageTransformer { private infix fun FieldSelector.after(field: FieldSelector) = Action(move = this, after = field) private infix fun FieldSelector.with(field: FieldDefinition) = Action(replace = this, with = field) private infix fun Action.onlyIf(condition: FieldSelector) = listOf(Transform(listOf(condition), listOf(this))) + private infix fun String.where(selector: FieldSelector) = GroupSelector(this, listOf(selector)).apply { init(CONTEXT) } + private infix fun Action.onGroup(group: GroupSelector) = copy(group = group) } -} \ No newline at end of file +}