diff --git a/pom.xml b/pom.xml index 1536b45a..538ae000 100644 --- a/pom.xml +++ b/pom.xml @@ -285,6 +285,12 @@ 1.35 test + + org.junit.jupiter + junit-jupiter-params + 5.9.0 + test + diff --git a/src/main/java/io/tarantool/driver/mappers/converters/object/DefaultInstantToExtensionValueConverter.java b/src/main/java/io/tarantool/driver/mappers/converters/object/DefaultInstantToExtensionValueConverter.java index 091b9e13..c25be07d 100644 --- a/src/main/java/io/tarantool/driver/mappers/converters/object/DefaultInstantToExtensionValueConverter.java +++ b/src/main/java/io/tarantool/driver/mappers/converters/object/DefaultInstantToExtensionValueConverter.java @@ -11,6 +11,8 @@ import java.nio.ByteOrder; import java.time.Instant; +import static io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToInstantConverter.DATETIME_TYPE; + /** * Default {@link java.time.Instant} to {@link ExtensionValue} converter * @@ -21,8 +23,6 @@ public class DefaultInstantToExtensionValueConverter implements ObjectConverter< private static final long serialVersionUID = 20221025L; - private static final byte DATETIME_TYPE = 0x04; - private byte[] toBytes(Instant value) { long seconds = value.getEpochSecond(); Integer nano = value.getNano(); diff --git a/src/main/java/io/tarantool/driver/mappers/converters/object/DefaultOffsetDateTimeToExtensionValueConverter.java b/src/main/java/io/tarantool/driver/mappers/converters/object/DefaultOffsetDateTimeToExtensionValueConverter.java new file mode 100644 index 00000000..46f70303 --- /dev/null +++ b/src/main/java/io/tarantool/driver/mappers/converters/object/DefaultOffsetDateTimeToExtensionValueConverter.java @@ -0,0 +1,81 @@ +package io.tarantool.driver.mappers.converters.object; + +import io.tarantool.driver.mappers.converters.ObjectConverter; +import org.msgpack.value.ExtensionValue; +import org.msgpack.value.ValueFactory; + +import java.nio.ByteBuffer; +import java.time.OffsetDateTime; + +import static io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToInstantConverter.DATETIME_TYPE; +import static io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToOffsetDateTimeConverter.SECONDS_PER_MINUTE; +import static java.nio.ByteOrder.LITTLE_ENDIAN; +import static java.time.ZoneOffset.UTC; + +/** + * Default {@link ExtensionValue} to {@link java.time.OffsetDateTime} converter. + * + * @author Valeriy Vyrva + */ +public class DefaultOffsetDateTimeToExtensionValueConverter implements ObjectConverter { + + private static final long serialVersionUID = 20231027114017L; + + /** + * Will contain only requited part: + *
    + *
  1. {@code 8 bytes}: Seconds since Epoch.
  2. + *
+ * + * @see struct datetime + */ + private static final int BUFFER_SIZE_COMPACT = Long.BYTES; + /** + * Will contain and required and optional parts: + *
    + *
  1. {@code 8 bytes}: Seconds since Epoch.
  2. + *
  3. {@code 4 bytes}: Nanoseconds.
  4. + *
  5. {@code 2 bytes}: Offset in minutes from UTC.
  6. + *
  7. {@code 2 bytes}: Olson timezone id.
  8. + *
+ * The "timezone id" is not used on Java. + * + * @see struct datetime + */ + private static final int BUFFER_SIZE_COMPLETE = Long.BYTES + Integer.BYTES + Short.BYTES + Short.BYTES; + + @Override + public ExtensionValue toValue(OffsetDateTime object) { + return ValueFactory.newExtension(DATETIME_TYPE, toBytes(object)); + } + + /** + * Encode java object into protocol level representation. + * + * @param object Object to encode + * @return Protocol level representation + * @see + * + * serialization schema + * @see + * struct datetime + * @see + * datetime_pack + * @see + * datetime_unpack + */ + private byte[] toBytes(OffsetDateTime object) { + boolean isCompact = object.getNano() == 0 && object.getOffset().equals(UTC); + ByteBuffer buffer = ByteBuffer.wrap(new byte[isCompact ? BUFFER_SIZE_COMPACT : BUFFER_SIZE_COMPLETE]); + buffer.order(LITTLE_ENDIAN); + //Required part + buffer.putLong(object.toEpochSecond()); + //Optional part + if (!isCompact) { + buffer.putInt(object.getNano()); + buffer.putShort((short) (object.getOffset().getTotalSeconds() / SECONDS_PER_MINUTE)); + } + return buffer.array(); + } + +} diff --git a/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToInstantConverter.java b/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToInstantConverter.java index eb7b31e6..1f0ba4a8 100644 --- a/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToInstantConverter.java +++ b/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToInstantConverter.java @@ -16,7 +16,12 @@ public class DefaultExtensionValueToInstantConverter implements ValueConverter { private static final long serialVersionUID = 20221025L; - private static final byte DATETIME_TYPE = 0x04; + /** + * @see + * + * mp_extension_type#MP_DATETIME + */ + public static final byte DATETIME_TYPE = 0x04; private Instant fromBytes(byte[] bytes) { int size = bytes.length; diff --git a/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToOffsetDateTimeConverter.java b/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToOffsetDateTimeConverter.java new file mode 100644 index 00000000..a9ef9eea --- /dev/null +++ b/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToOffsetDateTimeConverter.java @@ -0,0 +1,62 @@ +package io.tarantool.driver.mappers.converters.value.defaults; + +import io.tarantool.driver.mappers.converters.ValueConverter; +import org.msgpack.value.ExtensionValue; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToInstantConverter.DATETIME_TYPE; +import static java.nio.ByteOrder.LITTLE_ENDIAN; +import static java.time.ZoneOffset.UTC; + +/** + * Default {@link ExtensionValue} to {@link java.time.OffsetDateTime} converter. + * + * @author Valeriy Vyrva + */ +public class DefaultExtensionValueToOffsetDateTimeConverter implements ValueConverter { + + private static final long serialVersionUID = 20231027114017L; + + public static final int SECONDS_PER_MINUTE = 60; + + @Override + public boolean canConvertValue(ExtensionValue value) { + return value.getType() == DATETIME_TYPE; + } + + @Override + public OffsetDateTime fromValue(ExtensionValue value) { + return fromBytes(value.getData()); + } + + /** + * Decode protocol level representation into java object. + * + * @param value Bytes from protocol level + * @return Decoded value + * @see + * + * serialization schema + * @see + * struct datetime + * @see + * datetime_pack + * @see + * datetime_unpack + */ + private OffsetDateTime fromBytes(byte[] value) { + ByteBuffer buffer = ByteBuffer.wrap(value); + buffer.order(LITTLE_ENDIAN); + return Instant + //Required part + .ofEpochSecond(buffer.getLong()) + //Optional part + .plusNanos(buffer.hasRemaining() ? buffer.getInt() : 0) + .atOffset(buffer.hasRemaining() ? ZoneOffset.ofTotalSeconds(buffer.getShort() * SECONDS_PER_MINUTE) : UTC); + } + +} diff --git a/src/main/java/io/tarantool/driver/mappers/factories/DefaultMessagePackMapperFactory.java b/src/main/java/io/tarantool/driver/mappers/factories/DefaultMessagePackMapperFactory.java index c085b7f4..03680fc3 100644 --- a/src/main/java/io/tarantool/driver/mappers/factories/DefaultMessagePackMapperFactory.java +++ b/src/main/java/io/tarantool/driver/mappers/factories/DefaultMessagePackMapperFactory.java @@ -12,6 +12,7 @@ import io.tarantool.driver.mappers.converters.object.DefaultLongArrayToArrayValueConverter; import io.tarantool.driver.mappers.converters.object.DefaultLongToIntegerValueConverter; import io.tarantool.driver.mappers.converters.object.DefaultNilValueToNullConverter; +import io.tarantool.driver.mappers.converters.object.DefaultOffsetDateTimeToExtensionValueConverter; import io.tarantool.driver.mappers.converters.object.DefaultPackableObjectConverter; import io.tarantool.driver.mappers.converters.object.DefaultShortToIntegerValueConverter; import io.tarantool.driver.mappers.converters.object.DefaultStringToStringValueConverter; @@ -21,6 +22,7 @@ import io.tarantool.driver.mappers.converters.value.defaults.DefaultBinaryValueToByteArrayConverter; import io.tarantool.driver.mappers.converters.value.defaults.DefaultBooleanValueToBooleanConverter; import io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToBigDecimalConverter; +import io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToOffsetDateTimeConverter; import io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToUUIDConverter; import io.tarantool.driver.mappers.converters.value.defaults.DefaultFloatValueToDoubleConverter; import io.tarantool.driver.mappers.converters.value.defaults.DefaultFloatValueToFloatConverter; @@ -46,6 +48,7 @@ import java.math.BigDecimal; import java.time.Instant; +import java.time.OffsetDateTime; import java.util.UUID; /** @@ -86,6 +89,8 @@ private DefaultMessagePackMapperFactory() { .withValueConverter(ValueType.EXTENSION, BigDecimal.class, new DefaultExtensionValueToBigDecimalConverter()) .withValueConverter(ValueType.EXTENSION, Instant.class, new DefaultExtensionValueToInstantConverter()) + .withValueConverter(ValueType.EXTENSION, OffsetDateTime.class, + new DefaultExtensionValueToOffsetDateTimeConverter()) .withValueConverter(ValueType.NIL, Object.class, new DefaultNilValueToNullConverter()) //TODO: Potential issue https://github.com/tarantool/cartridge-java/issues/118 .withObjectConverter(Character.class, StringValue.class, new DefaultCharacterToStringValueConverter()) @@ -102,6 +107,8 @@ private DefaultMessagePackMapperFactory() { .withObjectConverter(BigDecimal.class, ExtensionValue.class, new DefaultBigDecimalToExtensionValueConverter()) .withObjectConverter(Instant.class, ExtensionValue.class, new DefaultInstantToExtensionValueConverter()) + .withObjectConverter(OffsetDateTime.class, ExtensionValue.class, + new DefaultOffsetDateTimeToExtensionValueConverter()) .build(); } diff --git a/src/test/java/io/tarantool/driver/integration/ConvertersWithClusterClientIT.java b/src/test/java/io/tarantool/driver/integration/ConvertersWithClusterClientIT.java index 527069c2..8d0a713d 100644 --- a/src/test/java/io/tarantool/driver/integration/ConvertersWithClusterClientIT.java +++ b/src/test/java/io/tarantool/driver/integration/ConvertersWithClusterClientIT.java @@ -13,10 +13,14 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.time.Instant; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Collections; import java.util.UUID; import java.nio.charset.StandardCharsets; @@ -110,4 +114,94 @@ public void test_boxOperations_shouldWorkWithVarbinary() throws Exception { List byteListFromTarantool = Utils.convertBytesToByteList(bytesFromTarantool); Assertions.assertEquals(byteList, byteListFromTarantool); } + + @ParameterizedTest(name = "[{index}] {0}") + @CsvSource(delimiter = '|', value = { + "Construct 'compact' value (zero nanoseconds, offset and timezone)" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17})" + + " | 2023-10-25T13:55:17Z", + "Construct 'complete' (has nanos) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983})" + + " | 2023-10-25T13:55:17.071983Z", + "Construct 'complete' (has nanos and positive offset) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983, tzoffset = 0+180})" + + " | 2023-10-25T13:55:17.071983+03:00", + "Construct 'complete' (has nanos and negative offset) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983, tzoffset = 0-180})" + + " | 2023-10-25T13:55:17.071983-03:00", + "Construct 'complete' (has nanos and timezone) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983, tz = " + + "'Europe/Isle_of_Man'})" + + " | 2023-10-25T13:55:17.071983+01:00", + "Parse with default format" + + " | parse('1970-01-01T00:00:00Z')" + + " | 1970-01-01T00:00:00Z", + "Parse with ISO8601 format and offset" + + " | parse('1970-01-01T00:00:00', {format = 'iso8601', tzoffset = 180})" + + " | 1970-01-01T00:00:00+03:00", + "Parse with RFC3339 format" + + " | parse('2017-12-27T18:45:32.999999-05:00', {format = 'rfc3339'})" + + " | 2017-12-27T18:45:32.999999-05:00", + }) + @EnabledIf("io.tarantool.driver.TarantoolUtils#versionWithInstant") + public void test_eval_shouldReturnOffsetDateTime( + String description, String expression, OffsetDateTime expected + ) throws Exception { + List result = client + .eval("return require('datetime')." + expression) + .get(); + + Assertions.assertEquals(expected, result.get(0), description); + } + + @ParameterizedTest(name = "[{index}] {0}") + @CsvSource(delimiter = '|', value = { + "Same 'compact' value" + + " | ''" + + " | 2023-10-25T13:55:17Z" + + " | 2023-10-25T13:55:17Z", + "Same 'complete' value" + + " | ''" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17.071983+03:00", + "Subtract day from 'complete' value" + + " | :sub({day = 1})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-24T13:55:17.071983+03:00", + "Clear timezone from 'complete' value" + + " | :set({tz = 'UTC'})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17.071983Z", + "Clear nanoseconds from 'complete' value" + + " | :set({nsec = 0})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17+03:00", + "Clear nanoseconds and timezone from 'complete' value" + + " | :set({nsec = 0, tz = 'UTC'})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17Z", + "Add nanoseconds into 'compact' value" + + " | :add({usec = 100500})" + + " | 2023-10-25T13:55:17Z" + + " | 2023-10-25T13:55:17.100500Z", + "Add nanoseconds into 'complete' (has offset) value" + + " | :add({usec = 100500})" + + " | 2023-10-25T13:55:17+03:00" + + " | 2023-10-25T13:55:17.100500+03:00", + "Add nanoseconds into 'complete' (has nanos) value" + + " | :add({usec = 100500})" + + " | 2023-10-25T13:55:17.023067+03:00" + + " | 2023-10-25T13:55:17.123567+03:00", + }) + @EnabledIf("io.tarantool.driver.TarantoolUtils#versionWithInstant") + public void test_eval_shouldHandleOffsetDateTime( + String description, String expression, OffsetDateTime original, OffsetDateTime expected + ) throws Exception { + List result = client + .eval("args = {...}; return args[1]" + expression, Collections.singleton(original)) + .get(); + + Assertions.assertEquals(expected, result.get(0), description); + } + } diff --git a/src/test/java/io/tarantool/driver/integration/ConvertersWithProxyClientIT.java b/src/test/java/io/tarantool/driver/integration/ConvertersWithProxyClientIT.java index 30809139..20dd6b13 100644 --- a/src/test/java/io/tarantool/driver/integration/ConvertersWithProxyClientIT.java +++ b/src/test/java/io/tarantool/driver/integration/ConvertersWithProxyClientIT.java @@ -13,8 +13,12 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Collections; import java.util.UUID; import java.nio.charset.StandardCharsets; @@ -120,4 +124,94 @@ public void test_crudOperations_shouldWorkWithBytesAsString() throws Exception { List byteListFromTarantool = Utils.convertBytesToByteList(bytesFromTarantool); Assertions.assertEquals(byteList, byteListFromTarantool); } + + @ParameterizedTest(name = "[{index}] {0}") + @CsvSource(delimiter = '|', value = { + "Construct 'compact' value (zero nanoseconds, offset and timezone)" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17})" + + " | 2023-10-25T13:55:17Z", + "Construct 'complete' (has nanos) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983})" + + " | 2023-10-25T13:55:17.071983Z", + "Construct 'complete' (has nanos and positive offset) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983, tzoffset = 0+180})" + + " | 2023-10-25T13:55:17.071983+03:00", + "Construct 'complete' (has nanos and negative offset) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983, tzoffset = 0-180})" + + " | 2023-10-25T13:55:17.071983-03:00", + "Construct 'complete' (has nanos and timezone) value" + + " | new({year = 2023, month = 10, day = 25, hour = 13, min = 55, sec = 17, usec = 71983, tz = " + + "'Europe/Isle_of_Man'})" + + " | 2023-10-25T13:55:17.071983+01:00", + "Parse with default format" + + " | parse('1970-01-01T00:00:00Z')" + + " | 1970-01-01T00:00:00Z", + "Parse with ISO8601 format and offset" + + " | parse('1970-01-01T00:00:00', {format = 'iso8601', tzoffset = 180})" + + " | 1970-01-01T00:00:00+03:00", + "Parse with RFC3339 format" + + " | parse('2017-12-27T18:45:32.999999-05:00', {format = 'rfc3339'})" + + " | 2017-12-27T18:45:32.999999-05:00", + }) + @EnabledIf("io.tarantool.driver.TarantoolUtils#versionWithInstant") + public void test_eval_shouldReturnOffsetDateTime( + String description, String expression, OffsetDateTime expected + ) throws Exception { + List result = client + .eval("return require('datetime')." + expression) + .get(); + + Assertions.assertEquals(expected, result.get(0), description); + } + + @ParameterizedTest(name = "[{index}] {0}") + @CsvSource(delimiter = '|', value = { + "Same 'compact' value" + + " | ''" + + " | 2023-10-25T13:55:17Z" + + " | 2023-10-25T13:55:17Z", + "Same 'complete' value" + + " | ''" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17.071983+03:00", + "Subtract day from 'complete' value" + + " | :sub({day = 1})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-24T13:55:17.071983+03:00", + "Clear timezone from 'complete' value" + + " | :set({tz = 'UTC'})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17.071983Z", + "Clear nanoseconds from 'complete' value" + + " | :set({nsec = 0})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17+03:00", + "Clear nanoseconds and timezone from 'complete' value" + + " | :set({nsec = 0, tz = 'UTC'})" + + " | 2023-10-25T13:55:17.071983+03:00" + + " | 2023-10-25T13:55:17Z", + "Add nanoseconds into 'compact' value" + + " | :add({usec = 100500})" + + " | 2023-10-25T13:55:17Z" + + " | 2023-10-25T13:55:17.100500Z", + "Add nanoseconds into 'complete' (has offset) value" + + " | :add({usec = 100500})" + + " | 2023-10-25T13:55:17+03:00" + + " | 2023-10-25T13:55:17.100500+03:00", + "Add nanoseconds into 'complete' (has nanos) value" + + " | :add({usec = 100500})" + + " | 2023-10-25T13:55:17.023067+03:00" + + " | 2023-10-25T13:55:17.123567+03:00", + }) + @EnabledIf("io.tarantool.driver.TarantoolUtils#versionWithInstant") + public void test_eval_shouldHandleOffsetDateTime( + String description, String expression, OffsetDateTime original, OffsetDateTime expected + ) throws Exception { + List result = client + .eval("args = {...}; return args[1]" + expression, Collections.singleton(original)) + .get(); + + Assertions.assertEquals(expected, result.get(0), description); + } + } diff --git a/src/test/java/io/tarantool/driver/mappers/DefaultOffsetDateTimeConverterTest.java b/src/test/java/io/tarantool/driver/mappers/DefaultOffsetDateTimeConverterTest.java new file mode 100644 index 00000000..bc7527a6 --- /dev/null +++ b/src/test/java/io/tarantool/driver/mappers/DefaultOffsetDateTimeConverterTest.java @@ -0,0 +1,56 @@ +package io.tarantool.driver.mappers; + +import io.tarantool.driver.mappers.converters.object.DefaultOffsetDateTimeToExtensionValueConverter; +import io.tarantool.driver.mappers.converters.value.defaults.DefaultExtensionValueToOffsetDateTimeConverter; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.msgpack.core.MessageBufferPacker; +import org.msgpack.core.MessagePack; +import org.msgpack.value.ExtensionValue; + +import java.io.IOException; +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.testcontainers.shaded.org.bouncycastle.pqc.math.linearalgebra.ByteUtils.toHexString; + +public class DefaultOffsetDateTimeConverterTest { + + @ParameterizedTest(name = "[{index}] {0}: [{1}] should encoded as [{2}]") + @CsvSource({ + "Compact (contains only seconds since Epoch)" + + " , 2023-10-23T17:45:17Z, D7 04 2DB1366500000000", + "Complete (has nanoseconds)" + + " , 2023-10-23T17:45:17.1983Z, D8 04 2DB1366500000000 60D1D10B 0000 0000", + "Complete (has positive offset)" + + " , 2023-10-23T20:45:17+03:00, D8 04 2DB1366500000000 00000000 B400 0000", + "Complete (has negative offset)" + + " , 2023-10-23T14:45:17-03:00, D8 04 2DB1366500000000 00000000 4CFF 0000", + "Complete (has nanoseconds and offset)" + + " , 2023-10-23T14:45:17.1983-03:00, D8 04 2DB1366500000000 60D1D10B 4CFF 0000", + "Byte order check" + + " , 2023-10-23T14:45:18.1983-03:00, D8 04 2EB1366500000000 60D1D10B 4CFF 0000", + }) + void test_shouldReadValueWhatItWrite( + @SuppressWarnings("unused") String description, OffsetDateTime original, String expectedNetwork + ) throws IOException { + DefaultOffsetDateTimeToExtensionValueConverter encoder = new DefaultOffsetDateTimeToExtensionValueConverter(); + DefaultExtensionValueToOffsetDateTimeConverter decoder = new DefaultExtensionValueToOffsetDateTimeConverter(); + + assertTrue(encoder.canConvertObject(original), "Encoder should allow to encode java object"); + + ExtensionValue encoded = encoder.toValue(original); + try (MessageBufferPacker packer = MessagePack.newDefaultBufferPacker()) { + packer.packValue(encoded); + String protocol = toHexString(packer.toByteArray()).toUpperCase(); + assertEquals(expectedNetwork.replace(" ", ""), protocol, "Network representation"); + } + + assertTrue(decoder.canConvertValue(encoded), "Decoder should allow to decode value"); + + OffsetDateTime decoded = decoder.fromValue(encoded); + assertEquals(original, decoded, "Decoded value should be equals to original"); + } + +}