diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 64d5b43f..c3433257 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: strategy: fail-fast: false matrix: - tarantool-version: [ "2.11.8-ubuntu20.04", "3.4.2", "3.8.1" ] + tarantool-version: [ "2.11.8-ubuntu20.04", "3.4.2", "3.8.2" ] timeout-minutes: 40 steps: - uses: actions/checkout@v6 @@ -124,7 +124,7 @@ jobs: strategy: fail-fast: false matrix: - tarantool-version: [ "2.11.8-ubuntu20.04", "3.4.2", "3.8.1" ] + tarantool-version: [ "2.11.8-ubuntu20.04", "3.4.2", "3.8.2" ] timeout-minutes: 40 steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f174e8..a27cb0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ [LINEARIZABLE](https://github.com/tarantool/tarantool/blob/e643f88a5e0c6d9ef2789b2033088fd04e7d39ae/src/box/txn.h#L228) isolation level. +### Protocol + +- Added processing + for [MP_ERROR](https://www.tarantool.io/en/doc/latest/reference/internals/msgpack_extensions/#the-error-type), + which is returned as an object within a response of type OK. + ### Testcontainers - `CartridgeClusterContainer`: a failed `cartridge replicasets setup` from a YAML topology file diff --git a/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxError.java b/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxError.java index 04023d65..e6c47c4e 100644 --- a/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxError.java +++ b/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxError.java @@ -6,73 +6,101 @@ package io.tarantool.core.exceptions; import java.util.List; -import java.util.stream.Collectors; import org.msgpack.value.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_ERROR; -import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_ERROR_24; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_STACK; import io.tarantool.core.protocol.IProtoMessage; +import io.tarantool.core.protocol.requests.IProtoConstant; public class BoxError extends RuntimeException { private static final long serialVersionUID = -407469439059293836L; + private static final Logger LOGGER = LoggerFactory.getLogger(BoxError.class); private final int code; - private final String tarantoolMessage; - private final List stack; - - private StringBuilder sb; + private final BoxErrorValue error; + private final String err24; public static BoxError fromIProtoMessage(IProtoMessage packet) { - return new BoxError( - packet.getErrorCode(), - packet.getBodyStringValue(IPROTO_ERROR_24).asString(), - packet - .getBodyMapValue(IPROTO_ERROR) - .map() - .get(MP_ERROR_STACK) - .asArrayValue() - .list() - .stream() - .map(Value::asMapValue) - .map(BoxErrorStackItem::fromStruct) - .collect(Collectors.toList())); + final Value rawError = packet.getBodyValue(IProtoConstant.IPROTO_ERROR); + if (rawError == null || rawError.isNilValue()) { + LOGGER.error("IPROTO_ERROR must not be null!"); + throw new NullPointerException("IPROTO_ERROR must not be null!"); + } + final BoxErrorValue err = BoxErrorValue.from(rawError); + + final String err24 = extractErr24(packet.getBodyValue(IProtoConstant.IPROTO_ERROR_24)); + return new BoxError(packet.getErrorCode(), err24, err); } - private BoxError(int code, String tarantoolMessage, List stack) { + private BoxError(int code, String err24, BoxErrorValue error) { this.code = code; - this.tarantoolMessage = tarantoolMessage; - this.stack = stack; + this.err24 = err24; + this.error = error; } @Override public String getMessage() { - if (sb == null) { - sb = - new StringBuilder("BoxError{") - .append("code=") - .append(code) - .append(", message='") - .append(tarantoolMessage) - .append('\'') - .append(", stack=") - .append(stack) - .append('}'); - } - return this.sb.toString(); + return "BoxError{code='" + + this.code + + "', message='" + + this.err24 + + "', stack=" + + this.error.stack() + + '}'; } + /** + * @deprecated use {@link #tarantoolMessage()} instead this. + */ + @Deprecated public String getTarantoolMessage() { - return tarantoolMessage; + return tarantoolMessage(); } + /** Returns {@link IProtoConstant#IPROTO_ERROR_24} value. */ + public String tarantoolMessage() { + return this.err24; + } + + /** + * @deprecated use {@link #stack()} instead this. + */ + @Deprecated public List getTarantoolStack() { - return this.stack; + return this.error.stack(); + } + + /** Returns {@link IProtoConstant#MP_ERROR_STACK} errors stack. */ + public List stack() { + return this.error.stack(); } + /** + * @deprecated use {@link #errorCode()} instead this. + */ + @Deprecated public int getErrorCode() { + return errorCode(); + } + + /** Returns error code from response. */ + public int errorCode() { return this.code; } + + private static String extractErr24(Value rawErr24) { + if (rawErr24 == null || rawErr24.isNilValue()) { + LOGGER.error("ERROR_24 must not be null! Current: '{}'", rawErr24); + throw new NullPointerException("ERROR_24 must not be null!"); + } + + if (!rawErr24.isStringValue()) { + LOGGER.error("IPROTO_ERROR_24 must be String. Current: '{}'", rawErr24); + throw new IllegalArgumentException("IPROTO_ERROR_24 must be String!"); + } + return rawErr24.asStringValue().asString(); + } } diff --git a/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxErrorStackItem.java b/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxErrorStackItem.java index 27912bc6..cd3b4a90 100644 --- a/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxErrorStackItem.java +++ b/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxErrorStackItem.java @@ -5,113 +5,374 @@ package io.tarantool.core.exceptions; +import java.io.IOException; import java.util.Map; +import java.util.Objects; import org.msgpack.value.MapValue; import org.msgpack.value.Value; +import org.msgpack.value.ValueFactory; +import org.msgpack.value.ValueFactory.MapBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_ERRCODE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_ERRNO; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_FIELDS; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_FILE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_LINE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_MESSAGE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_TYPE; +import io.tarantool.core.protocol.requests.IProtoConstant; public class BoxErrorStackItem { + private static final Logger LOGGER = LoggerFactory.getLogger(BoxErrorStackItem.class); + + /** + * Type that implies source, as in {@code error_object.base_type}, for example {@code + * “ClientError”} + * + * @apiNote never null! Unknown type is empty string. + */ private final String type; + + /** + * Line number in source code file, as in {@code error_object.trace}. + * + * @apiNote never null! Unknown line is 0! + */ private final long line; + + /** + * Source code file where error was caught, as in {@code error_object.trace}. + * + * @apiNote never null! Unknown file is empty string. + */ private final String file; + + /** + * Text of reason, as in {@code error_object.message}. + * + * @apiNote never null! Unknown message is empty string! + */ private final String message; - private final long errno; + + /** + * Ordinal number of the error, as in {@code error_object.errno}. Not to be confused with {@code + * MP_ERROR_ERRCODE}. + * + * @apiNote never null! Unknown errNo is 0! + */ + private final long errNo; + + /** + * Number of the error as defined in errcode.h, as in {@code error_object.code}, which can also be + * retrieved with the {@code C} function {@code box_error_code()}. The value here will be the same + * as the lower part of the Response-Code-Indicator value. + * + * @apiNote never null! Unknown code is 0! + */ private final long code; - private final MapValue details; - private StringBuilder sb; + /** + * Additional fields depending on error type. For example, if {@code MP_ERROR_TYPE} is {@code + * "AccessDeniedError”}, then {@code MP_ERROR_FIELDS} will include {@code “object_type”}, {@code + * “object_name”}, {@code “access_type”}. This field will be omitted from the response body if + * there are no additional fields available. + * + * @apiNote may be null! + */ + private final MapValue fields; public static BoxErrorStackItem fromStruct(MapValue struct) { - Map mapStruct = struct.map(); - Value fields = mapStruct.get(MP_ERROR_FIELDS); - return new BoxErrorStackItem( - mapStruct.get(MP_ERROR_TYPE).asStringValue().asString(), - mapStruct.get(MP_ERROR_LINE).asIntegerValue().asLong(), - mapStruct.get(MP_ERROR_FILE).asStringValue().asString(), - mapStruct.get(MP_ERROR_MESSAGE).asStringValue().asString(), - mapStruct.get(MP_ERROR_ERRNO).asIntegerValue().asLong(), - mapStruct.get(MP_ERROR_ERRCODE).asIntegerValue().asLong(), - fields != null ? fields.asMapValue() : null); + if (struct == null || struct.map().isEmpty()) { + LOGGER.error("IPROTO_STACK object must have any fields! Current: 'null'"); + throw new IllegalArgumentException( + "IPROTO_STACK object must have any fields! Current: 'null'"); + } + + final Map rawStackObject = struct.map(); + + final String type = + parseString(rawStackObject.get(IProtoConstant.MP_ERROR_TYPE), "MP_ERROR_TYPE"); + final String file = + parseString(rawStackObject.get(IProtoConstant.MP_ERROR_FILE), "MP_ERROR_FILE"); + final String message = + parseString(rawStackObject.get(IProtoConstant.MP_ERROR_MESSAGE), "MP_ERROR_MESSAGE"); + final long line = parseLong(rawStackObject.get(IProtoConstant.MP_ERROR_LINE), "MP_ERROR_LINE"); + final long errNo = + parseLong(rawStackObject.get(IProtoConstant.MP_ERROR_ERRNO), "MP_ERROR_ERRNO"); + final long code = + parseLong(rawStackObject.get(IProtoConstant.MP_ERROR_ERRCODE), "MP_ERROR_ERRCODE"); + final MapValue fields = parseFields(rawStackObject.get(IProtoConstant.MP_ERROR_FIELDS)); + + return BoxErrorStackItem.builder() + .withType(type) + .withLine(line) + .withFile(file) + .withMessage(message) + .withErrNo(errNo) + .withCode(code) + .withFields(fields) + .build(); } private BoxErrorStackItem( - String type, - long line, - String file, - String message, - long errno, - long code, - MapValue details) { + String type, long line, String file, String message, long errNo, long code, MapValue fields) { this.type = type; - this.line = line; this.file = file; this.message = message; - this.errno = errno; + this.errNo = errNo; this.code = code; - this.details = details; + this.line = line; + this.fields = fields; } + /** Use {@link #type()} instead this. */ + @Deprecated public String getType() { - return type; + return this.type; + } + + public String type() { + return this.type; } + /** Use {@link #line()} instead this. */ + @Deprecated public long getLine() { - return line; + return this.line; } + public long line() { + return this.line; + } + + /** Use {@link #file()} instead this. */ + @Deprecated public String getFile() { return file; } + public String file() { + return this.file; + } + + /** Use {@link #message()} instead this. */ + @Deprecated public String getMessage() { - return message; + return this.message; } + public String message() { + return this.message; + } + + /** Use {@link #errNo()} instead this. */ + @Deprecated public long getErrno() { - return errno; + return this.errNo; } + public long errNo() { + return this.errNo; + } + + /** Use {@link #code()} instead this. */ + @Deprecated public long getCode() { - return code; + return this.code; } + public long code() { + return this.code; + } + + /** Use {@link #fields()} instead this. */ + @Deprecated public MapValue getDetails() { - return details; + return this.fields; + } + + public MapValue fields() { + return this.fields; } @Override public String toString() { - if (sb == null) { - sb = - new StringBuilder("BoxErrorStackItem{") - .append("type='") - .append(type) - .append('\'') - .append(", line=") - .append(line) - .append(", file='") - .append(file) - .append('\'') - .append(", message='") - .append(message) - .append('\'') - .append(", errno=") - .append(errno) - .append(", code=") - .append(code) - .append(", details=") - .append(details) - .append('}'); - } - return sb.toString(); + return "BoxErrorStackItem{" + + "type='" + + this.type + + "', line='" + + this.line + + "', file='" + + this.file + + "', message='" + + this.message + + "', errno='" + + this.errNo + + "', code='" + + this.code + + "', fields='" + + this.fields + + "'}"; + } + + public MapValue serialize() throws IOException { + final MapBuilder builder = + ValueFactory.newMapBuilder() + .put(IProtoConstant.MP_ERROR_TYPE, ValueFactory.newString(this.type)) + .put(IProtoConstant.MP_ERROR_FILE, ValueFactory.newString(this.file)) + .put(IProtoConstant.MP_ERROR_MESSAGE, ValueFactory.newString(this.message)) + .put(IProtoConstant.MP_ERROR_LINE, ValueFactory.newInteger(this.line)) + .put(IProtoConstant.MP_ERROR_ERRNO, ValueFactory.newInteger(this.errNo)) + .put(IProtoConstant.MP_ERROR_ERRCODE, ValueFactory.newInteger(this.code)); + if (this.fields != null) { + builder.put(IProtoConstant.MP_ERROR_FIELDS, this.fields); + } + return builder.build(); + } + + private static MapValue parseFields(Value raw) { + if (raw == null || raw.isNilValue()) { + return null; + } + if (!raw.isMapValue()) { + LOGGER.error("MP_ERROR_FIELDS must be map! Current: '{}'", raw); + throw new IllegalArgumentException("MP_ERROR_FIELDS must be map!"); + } + return raw.asMapValue(); + } + + private static String parseString(Value raw, String fieldName) { + if (raw == null || raw.isNilValue()) { + LOGGER.error("'{}' must not be null! Current: '{}'", fieldName, raw); + throw new NullPointerException(fieldName + " must not be null!. Current: " + raw); + } + + if (!raw.isStringValue()) { + LOGGER.error("'{}' must be String! Current: '{}'", fieldName, raw); + throw new IllegalArgumentException(fieldName + " must be String!"); + } + return raw.asStringValue().asString(); + } + + private static long parseLong(Value raw, String fieldName) { + if (raw == null || raw.isNilValue()) { + LOGGER.error("'{}' must not be null! Current: '{}'", fieldName, raw); + throw new NullPointerException(fieldName + " must not be null!. Current: " + raw); + } + + if (!raw.isIntegerValue()) { + LOGGER.error("'{}' must be Long! Current: '{}'", fieldName, raw); + throw new IllegalArgumentException(fieldName + " must be Long!"); + } + return raw.asIntegerValue().asLong(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + /** + * Type that implies source, as in {@code error_object.base_type}, for example {@code + * “ClientError”} + * + * @apiNote never null! Unknown type is empty string. + */ + private String type = ""; + + /** + * Line number in source code file, as in {@code error_object.trace}. + * + * @apiNote never null! Unknown line is 0! + */ + private long line = 0L; + + /** + * Source code file where error was caught, as in {@code error_object.trace}. + * + * @apiNote never null! Unknown file is empty string. + */ + private String file = ""; + + /** + * Text of reason, as in {@code error_object.message}. + * + * @apiNote never null! Unknown message is empty string! + */ + private String message = ""; + + /** + * Ordinal number of the error, as in {@code error_object.errno}. Not to be confused with {@code + * MP_ERROR_ERRCODE}. + * + * @apiNote never null! Unknown errNo is 0! + */ + private long errNo = 0L; + + /** + * Number of the error as defined in errcode.h, as in {@code error_object.code}, which can also + * be retrieved with the {@code C} function {@code box_error_code()}. The value here will be the + * same as the lower part of the Response-Code-Indicator value. + * + * @apiNote never null! Unknown code is 0! + */ + private long code = 0L; + + /** + * Additional fields depending on error type. For example, if {@code MP_ERROR_TYPE} is {@code + * "AccessDeniedError”}, then {@code MP_ERROR_FIELDS} will include {@code “object_type”}, {@code + * “object_name”}, {@code “access_type”}. This field will be omitted from the response body if + * there are no additional fields available. + * + * @apiNote may be null! + */ + private MapValue fields = null; + + public Builder withType(String type) { + this.type = Objects.requireNonNull(type, "type must not be null!"); + return this; + } + + public Builder withLine(long line) { + if (line < 0) { + throw new IllegalArgumentException("line must be >= 0"); + } + this.line = line; + return this; + } + + public Builder withFile(String file) { + this.file = Objects.requireNonNull(file, "file must not be null!"); + return this; + } + + public Builder withMessage(String message) { + this.message = Objects.requireNonNull(message, "message must not be null!"); + return this; + } + + public Builder withErrNo(long errNo) { + if (errNo < 0) { + throw new IllegalArgumentException("errNo must be >= 0"); + } + this.errNo = errNo; + return this; + } + + public Builder withCode(long code) { + if (code < 0) { + throw new IllegalArgumentException("code must be >= 0"); + } + this.code = code; + return this; + } + + public Builder withFields(MapValue fields) { + this.fields = fields; + return this; + } + + public BoxErrorStackItem build() { + return new BoxErrorStackItem( + this.type, this.line, this.file, this.message, this.errNo, this.code, this.fields); + } } } diff --git a/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxErrorValue.java b/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxErrorValue.java new file mode 100644 index 00000000..9d0c81a6 --- /dev/null +++ b/tarantool-core/src/main/java/io/tarantool/core/exceptions/BoxErrorValue.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.core.exceptions; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.msgpack.core.MessagePacker; +import org.msgpack.value.ArrayValue; +import org.msgpack.value.Value; +import org.msgpack.value.ValueFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.tarantool.core.protocol.requests.IProtoConstant; + +/** + * Representation of an {@code MP_ERROR} object in Tarantool. + * + * @see Documentation + */ +public final class BoxErrorValue { + + private static final Logger LOGGER = LoggerFactory.getLogger(BoxErrorValue.class); + + /** + * @see BoxErrorStackItem + */ + private final List stack; + + /** + * Creates {@link BoxErrorValue} from list of stack items. + * + * @param stack stack items. Never null and empty! + */ + public BoxErrorValue(List stack) { + this.stack = Objects.requireNonNull(stack, "stack must not be null!"); + if (stack.isEmpty()) { + throw new IllegalArgumentException("MP_ERROR must have at least 1 stack object!"); + } + } + + /** + * Parses an {@code MP_ERROR} object from a msgpack map. + * + * @param error the {@code MP_ERROR} object represented as a msgpack map. May be null or nil. + * @return the parsed {@code MP_ERROR} instance or {@code null} if the value is null or nil + */ + public static BoxErrorValue from(Value error) { + + if (error == null || error.isNilValue()) { + return null; + } + return new BoxErrorValue(parseStack(error)); + } + + public void serialize(MessagePacker packer) throws IOException { + final List rawStack = new ArrayList<>(this.stack.size()); + for (BoxErrorStackItem stackItem : this.stack) { + Objects.requireNonNull(stackItem, "Stack item must not be null!"); + rawStack.add(stackItem.serialize()); + } + packer.packMapHeader(1); + packer.packValue(IProtoConstant.MP_ERROR_STACK); + packer.packValue(ValueFactory.newArray(rawStack)); + } + + /** + * @see BoxErrorStackItem + */ + public List stack() { + return this.stack; + } + + private static List parseStack(Value errorValue) { + if (!errorValue.isMapValue()) { + LOGGER.error("IPROTO_ERROR must be map. Current: '{}'", errorValue); + throw new IllegalArgumentException("IPROTO_ERROR must be map"); + } + + final Map errorMap = errorValue.asMapValue().map(); + final Value rawStack = errorMap.get(IProtoConstant.MP_ERROR_STACK); + if (rawStack == null || rawStack.isNilValue()) { + throw new NullPointerException("MP_ERROR_STACK must not be null!"); + } + + if (!rawStack.isArrayValue()) { + LOGGER.error("IPROTO_STACK must be array! Current: '{}'", rawStack); + throw new IllegalArgumentException("IPROTO_STACK must be array!"); + } + + final ArrayValue stackArray = rawStack.asArrayValue(); + final List result = new ArrayList<>(stackArray.size()); + for (Value item : stackArray) { + if (item == null || item.isNilValue()) { + throw new NullPointerException("Stack item must not be null!"); + } + if (!item.isMapValue()) { + LOGGER.error("IPROTO_STACK objects must be map! Current: '{}'", item); + throw new IllegalArgumentException("IPROTO_STACK objects must be map!"); + } + result.add(BoxErrorStackItem.fromStruct(item.asMapValue())); + } + if (result.isEmpty()) { + throw new IllegalArgumentException("IPROTO_STACK must have at least 1 stack item!"); + } + return Collections.unmodifiableList(result); + } +} diff --git a/tarantool-core/src/main/java/io/tarantool/core/protocol/fsm/RequestStateMachine.java b/tarantool-core/src/main/java/io/tarantool/core/protocol/fsm/RequestStateMachine.java index 0ce8f1f4..1f935964 100644 --- a/tarantool-core/src/main/java/io/tarantool/core/protocol/fsm/RequestStateMachine.java +++ b/tarantool-core/src/main/java/io/tarantool/core/protocol/fsm/RequestStateMachine.java @@ -105,7 +105,12 @@ public boolean process(IProtoResponse message) { } if (message.isError()) { - promise.completeExceptionally(BoxError.fromIProtoMessage(message)); + try { + promise.completeExceptionally(BoxError.fromIProtoMessage(message)); + } catch (Exception ex) { + promise.completeExceptionally(ex); + } + } else { promise.complete(message); } diff --git a/tarantool-core/src/main/java/io/tarantool/core/protocol/requests/IProtoConstant.java b/tarantool-core/src/main/java/io/tarantool/core/protocol/requests/IProtoConstant.java index 3fa559f3..d4f4286b 100644 --- a/tarantool-core/src/main/java/io/tarantool/core/protocol/requests/IProtoConstant.java +++ b/tarantool-core/src/main/java/io/tarantool/core/protocol/requests/IProtoConstant.java @@ -69,6 +69,7 @@ public interface IProtoConstant { byte IPROTO_EXT_DECIMAL = 0x01; byte IPROTO_EXT_UUID = 0x02; + byte IPROTO_EXT_ERROR = 0x03; byte IPROTO_EXT_DATETIME = 0x04; byte IPROTO_EXT_INTERVAL = 0x06; byte IPROTO_EXT_TUPLE = 0x07; diff --git a/tarantool-core/src/test/java/io/tarantool/core/integration/IProtoClientTest.java b/tarantool-core/src/test/java/io/tarantool/core/integration/IProtoClientTest.java index ea200d0a..29125eee 100644 --- a/tarantool-core/src/test/java/io/tarantool/core/integration/IProtoClientTest.java +++ b/tarantool-core/src/test/java/io/tarantool/core/integration/IProtoClientTest.java @@ -1488,13 +1488,13 @@ public void testAuthenticationErrorIncorrectPassword() throws Exception { "Incorrect password supplied for user 'user_a'", "User not found or supplied credentials are invalid"); - assertEquals(IPROTO_ERR_CREDS_MISMATCH, boxError.getErrorCode()); - assertEquals(1, boxError.getTarantoolStack().size()); - assertTrue(ERROR_MESSAGES.contains(boxError.getTarantoolMessage())); + assertEquals(IPROTO_ERR_CREDS_MISMATCH, boxError.errorCode()); + assertEquals(1, boxError.stack().size()); + assertTrue(ERROR_MESSAGES.contains(boxError.tarantoolMessage())); - BoxErrorStackItem item = boxError.getTarantoolStack().get(0); - assertEquals("ClientError", item.getType()); - assertTrue(ERROR_MESSAGES.contains(item.getMessage())); + BoxErrorStackItem item = boxError.stack().get(0); + assertEquals("ClientError", item.type()); + assertTrue(ERROR_MESSAGES.contains(item.message())); } @Test @@ -1511,13 +1511,13 @@ public void testAuthenticationErrorIncorrectUser() throws Exception { String ERROR_MESSAGE = "User not found or supplied credentials are invalid"; - assertEquals(IPROTO_ERR_CREDS_MISMATCH, boxError.getErrorCode()); - assertEquals(1, boxError.getTarantoolStack().size()); - assertEquals(ERROR_MESSAGE, boxError.getTarantoolMessage()); + assertEquals(IPROTO_ERR_CREDS_MISMATCH, boxError.errorCode()); + assertEquals(1, boxError.stack().size()); + assertEquals(ERROR_MESSAGE, boxError.tarantoolMessage()); - BoxErrorStackItem item = boxError.getTarantoolStack().get(0); - assertEquals("ClientError", item.getType()); - assertEquals(ERROR_MESSAGE, item.getMessage()); + BoxErrorStackItem item = boxError.stack().get(0); + assertEquals("ClientError", item.type()); + assertEquals(ERROR_MESSAGE, item.message()); } @Test @@ -1534,13 +1534,13 @@ public void testAuthenticationErrorNoSessionGranted() throws Exception { String ERROR_MESSAGE = "Session access to universe '' is denied for user 'user_c'"; - assertEquals(IPROTO_ERR_ACCESS_DENIED, boxError.getErrorCode()); - assertEquals(1, boxError.getTarantoolStack().size()); - assertEquals(ERROR_MESSAGE, boxError.getTarantoolMessage()); + assertEquals(IPROTO_ERR_ACCESS_DENIED, boxError.errorCode()); + assertEquals(1, boxError.stack().size()); + assertEquals(ERROR_MESSAGE, boxError.tarantoolMessage()); - BoxErrorStackItem item = boxError.getTarantoolStack().get(0); - assertEquals("AccessDeniedError", item.getType()); - assertEquals(ERROR_MESSAGE, item.getMessage()); + BoxErrorStackItem item = boxError.stack().get(0); + assertEquals("AccessDeniedError", item.type()); + assertEquals(ERROR_MESSAGE, item.message()); } @Test @@ -1563,14 +1563,14 @@ public void testAuthorizationErrorNoUsageGranted() throws Exception { ERROR_MESSAGE = "Execute access to universe '' is denied for user 'user_d'"; } - assertEquals(IPROTO_ERR_ACCESS_DENIED, boxError.getErrorCode()); - assertEquals(1, boxError.getTarantoolStack().size()); - String tarantoolMessage = boxError.getTarantoolMessage(); + assertEquals(IPROTO_ERR_ACCESS_DENIED, boxError.errorCode()); + assertEquals(1, boxError.stack().size()); + String tarantoolMessage = boxError.tarantoolMessage(); assertEquals(tarantoolMessage, ERROR_MESSAGE); - BoxErrorStackItem item = boxError.getTarantoolStack().get(0); - assertEquals("AccessDeniedError", item.getType()); - tarantoolMessage = item.getMessage(); + BoxErrorStackItem item = boxError.stack().get(0); + assertEquals("AccessDeniedError", item.type()); + tarantoolMessage = item.message(); assertEquals(tarantoolMessage, ERROR_MESSAGE); } @@ -1665,7 +1665,7 @@ public void testEvalError(String expression, Integer errorCode) throws Exception Exception ex = assertThrows(CompletionException.class, future::join); Throwable rootCause = findRootCause(ex); assertEquals(BoxError.class, rootCause.getClass()); - assertEquals(errorCode, ((BoxError) rootCause).getErrorCode()); + assertEquals(errorCode, ((BoxError) rootCause).errorCode()); } @ParameterizedTest @@ -1679,7 +1679,7 @@ public void testRawEvalError(String expression, Integer errorCode) throws Except Exception ex = assertThrows(CompletionException.class, future::join); Throwable rootCause = findRootCause(ex); assertEquals(BoxError.class, rootCause.getClass()); - assertEquals(errorCode, ((BoxError) rootCause).getErrorCode()); + assertEquals(errorCode, ((BoxError) rootCause).errorCode()); } @ParameterizedTest @@ -1843,20 +1843,20 @@ public void testCallError(String function, Integer errorCode, List> Exception ex = assertThrows(CompletionException.class, future::join); Throwable rootCause = findRootCause(ex); assertEquals(BoxError.class, rootCause.getClass()); - assertEquals(errorCode, ((BoxError) rootCause).getErrorCode()); - List stacktrace = ((BoxError) rootCause).getTarantoolStack(); + assertEquals(errorCode, ((BoxError) rootCause).errorCode()); + List stacktrace = ((BoxError) rootCause).stack(); assertEquals(stacktrace.size(), stack.size()); for (int i = 0; i < stack.size(); i++) { List stackline = stack.get(i); BoxErrorStackItem item = stacktrace.get(i); System.out.println(item.toString()); - assertEquals(stackline.get(0), item.getType()); + assertEquals(stackline.get(0), item.type()); if (tarantoolVersion != '3') { - assertEquals(stackline.get(1), item.getFile()); + assertEquals(stackline.get(1), item.file()); } - assertTrue(item.getMessage().contains(stackline.get(2).toString())); - assertEquals(stackline.get(3), item.getCode()); - assertEquals(stackline.get(4), item.getErrno()); + assertTrue(item.message().contains(stackline.get(2).toString())); + assertEquals(stackline.get(3), item.code()); + assertEquals(stackline.get(4), item.errNo()); } } @@ -1872,19 +1872,19 @@ public void testRawCallError(String function, Integer errorCode, List stacktrace = ((BoxError) rootCause).getTarantoolStack(); + assertEquals(errorCode, ((BoxError) rootCause).errorCode()); + List stacktrace = ((BoxError) rootCause).stack(); assertEquals(stacktrace.size(), stack.size()); for (int i = 0; i < stack.size(); i++) { List stackline = stack.get(i); BoxErrorStackItem item = stacktrace.get(i); - assertEquals(stackline.get(0), item.getType()); + assertEquals(stackline.get(0), item.type()); if (tarantoolVersion != '3') { - assertEquals(stackline.get(1), item.getFile()); + assertEquals(stackline.get(1), item.file()); } - assertTrue(item.getMessage().contains(stackline.get(2).toString())); - assertEquals(stackline.get(3), item.getCode()); - assertEquals(stackline.get(4), item.getErrno()); + assertTrue(item.message().contains(stackline.get(2).toString())); + assertEquals(stackline.get(3), item.code()); + assertEquals(stackline.get(4), item.errNo()); } } @@ -1938,7 +1938,7 @@ public void testPrepareWithWrongSpaceName() throws Exception { Exception ex = assertThrows(CompletionException.class, future::join); Throwable rootCause = findRootCause(ex); assertEquals(BoxError.class, rootCause.getClass()); - assertEquals(IPROTO_ERR_SPACE_DOES_NOT_EXIST, ((BoxError) rootCause).getErrorCode()); + assertEquals(IPROTO_ERR_SPACE_DOES_NOT_EXIST, ((BoxError) rootCause).errorCode()); assertTrue( rootCause.getMessage().contains("Space 'unknown_space' does not exist"), "Message should contain space error: " + rootCause.getMessage()); @@ -1954,9 +1954,8 @@ public void testExecuteWithWrongSpaceName() throws Exception { Exception ex = assertThrows(CompletionException.class, future::join); Throwable rootCause = findRootCause(ex); assertEquals(BoxError.class, rootCause.getClass()); - assertEquals(IPROTO_ERR_SPACE_DOES_NOT_EXIST, ((BoxError) rootCause).getErrorCode()); - assertEquals( - "Space 'unknown_space' does not exist", ((BoxError) rootCause).getTarantoolMessage()); + assertEquals(IPROTO_ERR_SPACE_DOES_NOT_EXIST, ((BoxError) rootCause).errorCode()); + assertEquals("Space 'unknown_space' does not exist", ((BoxError) rootCause).tarantoolMessage()); } @Test @@ -1970,7 +1969,7 @@ public void testRawExecuteWithWrongSpaceName() throws Exception { Exception ex = assertThrows(CompletionException.class, future::join); Throwable rootCause = findRootCause(ex); assertEquals(BoxError.class, rootCause.getClass()); - assertEquals(IPROTO_ERR_SPACE_DOES_NOT_EXIST, ((BoxError) rootCause).getErrorCode()); + assertEquals(IPROTO_ERR_SPACE_DOES_NOT_EXIST, ((BoxError) rootCause).errorCode()); assertTrue( rootCause.getMessage().contains("Space 'unknown_space' does not exist"), "Message should contain space error: " + rootCause.getMessage()); diff --git a/tarantool-core/src/test/java/io/tarantool/core/integration/MVCCStreamsTest.java b/tarantool-core/src/test/java/io/tarantool/core/integration/MVCCStreamsTest.java index 65a6f688..d82ec533 100644 --- a/tarantool-core/src/test/java/io/tarantool/core/integration/MVCCStreamsTest.java +++ b/tarantool-core/src/test/java/io/tarantool/core/integration/MVCCStreamsTest.java @@ -226,13 +226,13 @@ public void testStreamBeginAndCommitWithThrowingCall() throws Exception { Exception ex1 = assertThrows(CompletionException.class, callFuture::join); Throwable rootCause1 = findRootCause(ex1); assertEquals(BoxError.class, rootCause1.getClass()); - assertEquals(IPROTO_ERR_PROC_LUA, ((BoxError) rootCause1).getErrorCode()); + assertEquals(IPROTO_ERR_PROC_LUA, ((BoxError) rootCause1).errorCode()); CompletableFuture insertA2Future = client.insert(spaceAId, null, tupleA, opts); Exception ex2 = assertThrows(CompletionException.class, insertA2Future::join); Throwable rootCause2 = findRootCause(ex2); assertEquals(BoxError.class, rootCause2.getClass()); - assertEquals(IPROTO_ERR_TUPLE_FOUND, ((BoxError) rootCause2).getErrorCode()); + assertEquals(IPROTO_ERR_TUPLE_FOUND, ((BoxError) rootCause2).errorCode()); IProtoMessage messageInsertB = client.insert(spaceBId, null, tupleB, opts).get(); IProtoMessage messageCommit = client.commit(streamId).get(); diff --git a/tarantool-core/src/test/java/io/tarantool/core/unit/exceptions/BoxErrorTest.java b/tarantool-core/src/test/java/io/tarantool/core/unit/exceptions/BoxErrorTest.java index e59e72a4..47263095 100644 --- a/tarantool-core/src/test/java/io/tarantool/core/unit/exceptions/BoxErrorTest.java +++ b/tarantool-core/src/test/java/io/tarantool/core/unit/exceptions/BoxErrorTest.java @@ -5,42 +5,69 @@ package io.tarantool.core.unit.exceptions; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.msgpack.core.MessageBufferPacker; +import org.msgpack.core.MessagePack; import org.msgpack.value.MapValue; import org.msgpack.value.Value; import org.msgpack.value.ValueFactory; -import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_ERROR_BASE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_ERRCODE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_ERRNO; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_FIELDS; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_FILE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_LINE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_MESSAGE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_STACK; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_ERROR_TYPE; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_IPROTO_ERROR; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_IPROTO_ERROR_24; -import static io.tarantool.core.protocol.requests.IProtoConstant.MP_IPROTO_REQUEST_TYPE; import io.tarantool.core.exceptions.BoxError; import io.tarantool.core.exceptions.BoxErrorStackItem; +import io.tarantool.core.exceptions.BoxErrorValue; import io.tarantool.core.protocol.IProtoResponseImpl; +import io.tarantool.core.protocol.requests.IProtoConstant; +@TestInstance(Lifecycle.PER_CLASS) +@Execution(ExecutionMode.CONCURRENT) public class BoxErrorTest { + /** The field is not passed: the key is missing in the map. */ + private static final Value ABSENT = null; + + /** The field is passed, but with the nil value. */ + private static final Value NIL = ValueFactory.newNil(); + public static Stream dataForBoxErrorStackItem() { return Stream.of( - Arguments.of("1", "2", 3L, "4", 5L, 6L, ValueFactory.emptyMap()), - Arguments.of("1", "2", 3L, "4", 5L, 6L, null), + // All fields are passed, MP_ERROR_FIELDS is an empty map. Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), num(5), num(6), ValueFactory.emptyMap()), + "1", + "2", + 3L, + "4", + 5L, + 6L, + ValueFactory.emptyMap()), + // All fields are passed, MP_ERROR_FIELDS is a non-empty map. + Arguments.of( + struct( + str("1"), + str("2"), + num(3), + str("4"), + num(5), + num(6), + ValueFactory.newMap( + ValueFactory.newString("a"), ValueFactory.newString("b"), + ValueFactory.newInteger(1), ValueFactory.newInteger(2))), "1", "2", 3L, @@ -49,76 +76,285 @@ public static Stream dataForBoxErrorStackItem() { 6L, ValueFactory.newMap( ValueFactory.newString("a"), ValueFactory.newString("b"), - ValueFactory.newInteger(1), ValueFactory.newInteger(2)))); + ValueFactory.newInteger(1), ValueFactory.newInteger(2))), + // MP_ERROR_FIELDS is not passed: the fields are null. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), num(5), num(6), ABSENT), + "1", + "2", + 3L, + "4", + 5L, + 6L, + null), + // MP_ERROR_FIELDS = nil: the fields are null. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), num(5), num(6), NIL), + "1", + "2", + 3L, + "4", + 5L, + 6L, + null)); } @ParameterizedTest @MethodSource("dataForBoxErrorStackItem") public void testBoxErrorStackItem( - String type, - String file, - Long line, - String message, - Long errno, - Long errcode, - MapValue fields) { - ValueFactory.MapBuilder mp = ValueFactory.newMapBuilder(); - if (type != null) { - mp.put(MP_ERROR_TYPE, ValueFactory.newString(type)); - } - if (file != null) { - mp.put(MP_ERROR_FILE, ValueFactory.newString(file)); - } - if (line != null) { - mp.put(MP_ERROR_LINE, ValueFactory.newInteger(line)); - } - if (message != null) { - mp.put(MP_ERROR_MESSAGE, ValueFactory.newString(message)); - } - if (errno != null) { - mp.put(MP_ERROR_ERRNO, ValueFactory.newInteger(errno)); - } - if (errcode != null) { - mp.put(MP_ERROR_ERRCODE, ValueFactory.newInteger(errcode)); + MapValue struct, + String expectedType, + String expectedFile, + long expectedLine, + String expectedMessage, + long expectedErrNo, + long expectedErrCode, + MapValue expectedFields) { + final BoxErrorStackItem stack = BoxErrorStackItem.fromStruct(struct); + Assertions.assertEquals(expectedType, stack.type()); + Assertions.assertEquals(expectedFile, stack.file()); + Assertions.assertEquals(expectedLine, stack.line()); + Assertions.assertEquals(expectedMessage, stack.message()); + Assertions.assertEquals(expectedErrNo, stack.errNo()); + Assertions.assertEquals(expectedErrCode, stack.code()); + Assertions.assertEquals(expectedFields, stack.fields()); + } + + @ParameterizedTest + @MethodSource("dataForBoxErrorStackItem") + public void testBoxErrorStackItemSerialization( + MapValue struct, + String expectedType, + String expectedFile, + long expectedLine, + String expectedMessage, + long expectedErrNo, + long expectedErrCode, + MapValue expectedFields) + throws IOException { + final BoxErrorStackItem stack = BoxErrorStackItem.fromStruct(struct); + + final MapValue serialized = stack.serialize(); + // Tarantool does not accept nil values in the MP_ERROR fields. + for (Map.Entry entry : serialized.map().entrySet()) { + Assertions.assertFalse(entry.getValue().isNilValue()); } - if (fields != null) { - mp.put(MP_ERROR_FIELDS, fields); + + final BoxErrorStackItem restored = BoxErrorStackItem.fromStruct(serialized); + Assertions.assertEquals(expectedType, restored.type()); + Assertions.assertEquals(expectedFile, restored.file()); + Assertions.assertEquals(expectedLine, restored.line()); + Assertions.assertEquals(expectedMessage, restored.message()); + Assertions.assertEquals(expectedErrNo, restored.errNo()); + Assertions.assertEquals(expectedErrCode, restored.code()); + Assertions.assertEquals(expectedFields, restored.fields()); + } + + @Test + public void testBoxErrorValueSerialization() throws IOException { + final BoxErrorValue value = + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder() + .withType("ClientError") + .withLine(42L) + .withFile("file.lua") + .withMessage("message") + .withErrNo(7L) + .withCode(32L) + .withFields(null) + .build())); + + final Value unpacked; + try (MessageBufferPacker packer = MessagePack.newDefaultBufferPacker()) { + value.serialize(packer); + unpacked = MessagePack.newDefaultUnpacker(packer.toByteArray()).unpackValue(); } - BoxErrorStackItem res = BoxErrorStackItem.fromStruct(mp.build()); - assertEquals(type, res.getType()); - assertEquals(file, res.getFile()); - assertEquals(line, res.getLine()); - assertEquals(message, res.getMessage()); - assertEquals(errno, res.getErrno()); - assertEquals(errcode, res.getCode()); - assertEquals(fields, res.getDetails()); + // The MP_ERROR object is a map with the only MP_ERROR_STACK key. + Assertions.assertTrue(unpacked.isMapValue()); + final Map errorMap = unpacked.asMapValue().map(); + Assertions.assertEquals(1, errorMap.size()); + Assertions.assertTrue(errorMap.containsKey(IProtoConstant.MP_ERROR_STACK)); + + final BoxErrorValue restored = BoxErrorValue.from(unpacked); + Assertions.assertEquals("message", restored.stack().get(0).message()); + Assertions.assertEquals(1, restored.stack().size()); + + final BoxErrorStackItem stackItem = restored.stack().get(0); + Assertions.assertEquals("ClientError", stackItem.type()); + Assertions.assertEquals("file.lua", stackItem.file()); + Assertions.assertEquals(42L, stackItem.line()); + Assertions.assertEquals("message", stackItem.message()); + Assertions.assertEquals(7L, stackItem.errNo()); + Assertions.assertEquals(32L, stackItem.code()); + Assertions.assertNull(stackItem.fields()); + } + + @Test + public void testBoxErrorValueFromNil() { + // The MP_ERROR value may be nil: there is no object in this case. + Assertions.assertNull(BoxErrorValue.from(null)); + Assertions.assertNull(BoxErrorValue.from(ValueFactory.newNil())); + } + + @Test + public void testBoxErrorValueWithEmptyStack() { + // The MP_ERROR object must have at least one stack item. + Assertions.assertThrows( + IllegalArgumentException.class, () -> new BoxErrorValue(Collections.emptyList())); + } + + public static Stream dataForInvalidBoxErrorStackItem() { + return Stream.of( + // The structure is not passed at all. + Arguments.of( + null, + IllegalArgumentException.class, + "IPROTO_STACK object must have any fields! Current: 'null'"), + // The structure is passed, but empty. + Arguments.of( + ValueFactory.emptyMap(), + IllegalArgumentException.class, + "IPROTO_STACK object must have any fields! Current: 'null'"), + // MP_ERROR_TYPE has an invalid type. + Arguments.of( + struct(num(1), str("2"), num(3), str("4"), num(5), num(6), ValueFactory.emptyMap()), + IllegalArgumentException.class, + "MP_ERROR_TYPE must be String!"), + // MP_ERROR_FILE has an invalid type. + Arguments.of( + struct(str("1"), num(2), num(3), str("4"), num(5), num(6), ValueFactory.emptyMap()), + IllegalArgumentException.class, + "MP_ERROR_FILE must be String!"), + // MP_ERROR_LINE has an invalid type. + Arguments.of( + struct(str("1"), str("2"), str("3"), str("4"), num(5), num(6), ValueFactory.emptyMap()), + IllegalArgumentException.class, + "MP_ERROR_LINE must be Long!"), + // MP_ERROR_MESSAGE has an invalid type. + Arguments.of( + struct(str("1"), str("2"), num(3), num(4), num(5), num(6), ValueFactory.emptyMap()), + IllegalArgumentException.class, + "MP_ERROR_MESSAGE must be String!"), + // MP_ERROR_ERRNO has an invalid type. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), str("5"), num(6), ValueFactory.emptyMap()), + IllegalArgumentException.class, + "MP_ERROR_ERRNO must be Long!"), + // MP_ERROR_ERRCODE has an invalid type. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), num(5), str("6"), ValueFactory.emptyMap()), + IllegalArgumentException.class, + "MP_ERROR_ERRCODE must be Long!"), + // MP_ERROR_FIELDS has an invalid type. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), num(5), num(6), str("7")), + IllegalArgumentException.class, + "MP_ERROR_FIELDS must be map!"), + // MP_ERROR_TYPE is not passed. + Arguments.of( + struct(ABSENT, str("2"), num(3), str("4"), num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_TYPE must not be null!. Current: null"), + // MP_ERROR_TYPE = nil. + Arguments.of( + struct(NIL, str("2"), num(3), str("4"), num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_TYPE must not be null!. Current: null"), + // MP_ERROR_FILE is not passed. + Arguments.of( + struct(str("1"), ABSENT, num(3), str("4"), num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_FILE must not be null!. Current: null"), + // MP_ERROR_FILE = nil. + Arguments.of( + struct(str("1"), NIL, num(3), str("4"), num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_FILE must not be null!. Current: null"), + // MP_ERROR_LINE is not passed. + Arguments.of( + struct(str("1"), str("2"), ABSENT, str("4"), num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_LINE must not be null!. Current: null"), + // MP_ERROR_LINE = nil. + Arguments.of( + struct(str("1"), str("2"), NIL, str("4"), num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_LINE must not be null!. Current: null"), + // MP_ERROR_MESSAGE is not passed. + Arguments.of( + struct(str("1"), str("2"), num(3), ABSENT, num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_MESSAGE must not be null!. Current: null"), + // MP_ERROR_MESSAGE = nil. + Arguments.of( + struct(str("1"), str("2"), num(3), NIL, num(5), num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_MESSAGE must not be null!. Current: null"), + // MP_ERROR_ERRNO is not passed. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), ABSENT, num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_ERRNO must not be null!. Current: null"), + // MP_ERROR_ERRNO = nil. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), NIL, num(6), ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_ERRNO must not be null!. Current: null"), + // MP_ERROR_ERRCODE is not passed. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), num(5), ABSENT, ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_ERRCODE must not be null!. Current: null"), + // MP_ERROR_ERRCODE = nil. + Arguments.of( + struct(str("1"), str("2"), num(3), str("4"), num(5), NIL, ValueFactory.emptyMap()), + NullPointerException.class, + "MP_ERROR_ERRCODE must not be null!. Current: null")); + } + + @ParameterizedTest + @MethodSource("dataForInvalidBoxErrorStackItem") + public void testInvalidBoxErrorStackItem( + MapValue struct, Class expectedType, String expectedMessage) { + final Exception exception = + Assertions.assertThrows(expectedType, () -> BoxErrorStackItem.fromStruct(struct)); + Assertions.assertEquals(expectedMessage, exception.getMessage()); } public static Stream dataForBoxError() { + // The error message in the response is always the message of the top stack item. return Stream.of( Arguments.of( - IPROTO_ERROR_BASE, - "tarantool_message", - Arrays.asList(Arrays.asList("1", "2", 3L, "4", 5L, 6L, ValueFactory.emptyMap())), - "BoxError{code=0, message='tarantool_message', stack=[BoxErrorStackItem{type='1'," - + " line=3, file='2', message='4', errno=5, code=6, details={}}]}"), + IProtoConstant.IPROTO_ERROR_BASE, + "4", + Collections.singletonList( + Arrays.asList("1", "2", 3L, "4", 5L, 6L, ValueFactory.emptyMap())), + "4", + "BoxError{code='0', message='4', stack=[BoxErrorStackItem{type='1'," + + " line='3', file='2', message='4', errno='5', code='6', fields='{}'}]}"), Arguments.of( - IPROTO_ERROR_BASE, - "tarantool_message", + IProtoConstant.IPROTO_ERROR_BASE, + "4", Arrays.asList( Arrays.asList("1", "2", 3L, "4", 5L, 6L, ValueFactory.emptyMap()), Arrays.asList("8", "9", 10L, "11", 12L, 13L, ValueFactory.emptyMap())), - "BoxError{code=0, message='tarantool_message', stack=[BoxErrorStackItem{type='1'," - + " line=3, file='2', message='4', errno=5, code=6, details={}}," - + " BoxErrorStackItem{type='8', line=10, file='9', message='11', errno=12, code=13," - + " details={}}]}")); + "4", + "BoxError{code='0', message='4', stack=[BoxErrorStackItem{type='1'," + + " line='3', file='2', message='4', errno='5', code='6', fields='{}'}," + + " BoxErrorStackItem{type='8', line='10', file='9', message='11', errno='12'," + + " code='13', fields='{}'}]}")); } @ParameterizedTest @MethodSource("dataForBoxError") public void testBoxError( - int requestType, String tarantoolMessage, List> stack, String exceptionMessage) { + int requestType, + String tarantoolMessage, + List> stack, + String expectedMessage, + String exceptionMessage) { ArrayList encodedStack = new ArrayList<>(); for (List stackItem : stack) { String type = (String) stackItem.get(0); @@ -130,25 +366,25 @@ public void testBoxError( MapValue fields = (MapValue) stackItem.get(6); ValueFactory.MapBuilder mp = ValueFactory.newMapBuilder(); if (type != null) { - mp.put(MP_ERROR_TYPE, ValueFactory.newString(type)); + mp.put(IProtoConstant.MP_ERROR_TYPE, ValueFactory.newString(type)); } if (file != null) { - mp.put(MP_ERROR_FILE, ValueFactory.newString(file)); + mp.put(IProtoConstant.MP_ERROR_FILE, ValueFactory.newString(file)); } if (line != null) { - mp.put(MP_ERROR_LINE, ValueFactory.newInteger(line)); + mp.put(IProtoConstant.MP_ERROR_LINE, ValueFactory.newInteger(line)); } if (message != null) { - mp.put(MP_ERROR_MESSAGE, ValueFactory.newString(message)); + mp.put(IProtoConstant.MP_ERROR_MESSAGE, ValueFactory.newString(message)); } if (errno != null) { - mp.put(MP_ERROR_ERRNO, ValueFactory.newInteger(errno)); + mp.put(IProtoConstant.MP_ERROR_ERRNO, ValueFactory.newInteger(errno)); } if (errcode != null) { - mp.put(MP_ERROR_ERRCODE, ValueFactory.newInteger(errcode)); + mp.put(IProtoConstant.MP_ERROR_ERRCODE, ValueFactory.newInteger(errcode)); } if (fields != null) { - mp.put(MP_ERROR_FIELDS, fields); + mp.put(IProtoConstant.MP_ERROR_FIELDS, fields); } encodedStack.add(mp.build()); } @@ -156,17 +392,20 @@ public void testBoxError( BoxError result = BoxError.fromIProtoMessage( new IProtoResponseImpl( - ValueFactory.newMap(MP_IPROTO_REQUEST_TYPE, ValueFactory.newInteger(requestType)), ValueFactory.newMap( - MP_IPROTO_ERROR_24, ValueFactory.newString(tarantoolMessage), - MP_IPROTO_ERROR, - ValueFactory.newMap(MP_ERROR_STACK, ValueFactory.newArray(encodedStack))))); - assertEquals(exceptionMessage, result.getMessage()); - assertEquals(requestType - IPROTO_ERROR_BASE, result.getErrorCode()); - assertEquals(tarantoolMessage, result.getTarantoolMessage()); - assertEquals(stack.size(), result.getTarantoolStack().size()); + IProtoConstant.MP_IPROTO_REQUEST_TYPE, ValueFactory.newInteger(requestType)), + ValueFactory.newMap( + IProtoConstant.MP_IPROTO_ERROR_24, + ValueFactory.newString(tarantoolMessage), + IProtoConstant.MP_IPROTO_ERROR, + ValueFactory.newMap( + IProtoConstant.MP_ERROR_STACK, ValueFactory.newArray(encodedStack))))); + Assertions.assertEquals(exceptionMessage, result.getMessage()); + Assertions.assertEquals(requestType - IProtoConstant.IPROTO_ERROR_BASE, result.errorCode()); + Assertions.assertEquals(expectedMessage, result.tarantoolMessage()); + Assertions.assertEquals(stack.size(), result.stack().size()); for (int i = 0; i < stack.size(); i++) { - List expectedStackItem = stack.get(0); + List expectedStackItem = stack.get(i); String type = (String) expectedStackItem.get(0); String file = (String) expectedStackItem.get(1); Long line = (Long) expectedStackItem.get(2); @@ -175,15 +414,115 @@ public void testBoxError( Long errcode = (Long) expectedStackItem.get(5); MapValue fields = (MapValue) expectedStackItem.get(6); - BoxErrorStackItem boxErrorStackItem = result.getTarantoolStack().get(0); + BoxErrorStackItem boxErrorStackItem = result.stack().get(i); + + Assertions.assertEquals(type, boxErrorStackItem.type()); + Assertions.assertEquals(file, boxErrorStackItem.file()); + Assertions.assertEquals(line, boxErrorStackItem.line()); + Assertions.assertEquals(message, boxErrorStackItem.message()); + Assertions.assertEquals(errno, boxErrorStackItem.errNo()); + Assertions.assertEquals(errcode, boxErrorStackItem.code()); + Assertions.assertEquals(fields, boxErrorStackItem.fields()); + } + } - assertEquals(type, boxErrorStackItem.getType()); - assertEquals(file, boxErrorStackItem.getFile()); - assertEquals(line, boxErrorStackItem.getLine()); - assertEquals(message, boxErrorStackItem.getMessage()); - assertEquals(errno, boxErrorStackItem.getErrno()); - assertEquals(errcode, boxErrorStackItem.getCode()); - assertEquals(fields, boxErrorStackItem.getDetails()); + public static Stream dataForInvalidBoxError() { + return Stream.of( + // The error object is not passed in the response. + Arguments.of( + ValueFactory.newMap( + IProtoConstant.MP_IPROTO_ERROR_24, ValueFactory.newString("message")), + NullPointerException.class, + "IPROTO_ERROR must not be null!"), + // The error message is not passed in the response. + Arguments.of( + ValueFactory.newMap(IProtoConstant.MP_IPROTO_ERROR, errorValue()), + NullPointerException.class, + "ERROR_24 must not be null!"), + // The stack of the error object is empty. + Arguments.of( + ValueFactory.newMap( + IProtoConstant.MP_IPROTO_ERROR_24, + ValueFactory.newString("message"), + IProtoConstant.MP_IPROTO_ERROR, + ValueFactory.newMap(IProtoConstant.MP_ERROR_STACK, ValueFactory.newArray())), + IllegalArgumentException.class, + "IPROTO_STACK must have at least 1 stack item!")); + } + + @ParameterizedTest + @MethodSource("dataForInvalidBoxError") + public void testInvalidBoxError( + MapValue body, Class expectedType, String expectedMessage) { + final Exception exception = + Assertions.assertThrows( + expectedType, + () -> + BoxError.fromIProtoMessage( + new IProtoResponseImpl( + ValueFactory.newMap( + IProtoConstant.MP_IPROTO_REQUEST_TYPE, + ValueFactory.newInteger(IProtoConstant.IPROTO_ERROR_BASE)), + body))); + Assertions.assertEquals(expectedMessage, exception.getMessage()); + } + + /** Builds a valid MP_ERROR object with the single stack item. */ + private static MapValue errorValue() { + return ValueFactory.newMap( + IProtoConstant.MP_ERROR_STACK, + ValueFactory.newArray( + ValueFactory.newMap( + IProtoConstant.MP_ERROR_TYPE, + ValueFactory.newString("ClientError"), + IProtoConstant.MP_ERROR_FILE, + ValueFactory.newString("file.lua"), + IProtoConstant.MP_ERROR_MESSAGE, + ValueFactory.newString("message"), + IProtoConstant.MP_ERROR_LINE, + ValueFactory.newInteger(42), + IProtoConstant.MP_ERROR_ERRNO, + ValueFactory.newInteger(0), + IProtoConstant.MP_ERROR_ERRCODE, + ValueFactory.newInteger(32)))); + } + + private static Value str(String value) { + return ValueFactory.newString(value); + } + + private static Value num(long value) { + return ValueFactory.newInteger(value); + } + + /** + * Builds the error object map. The {@code null} parameter means that the key is not passed at + * all, {@link #NIL} - that the key is passed with the nil value. + */ + private static MapValue struct( + Value type, Value file, Value line, Value message, Value errno, Value errcode, Value fields) { + ValueFactory.MapBuilder mp = ValueFactory.newMapBuilder(); + if (type != null) { + mp.put(IProtoConstant.MP_ERROR_TYPE, type); + } + if (file != null) { + mp.put(IProtoConstant.MP_ERROR_FILE, file); + } + if (line != null) { + mp.put(IProtoConstant.MP_ERROR_LINE, line); + } + if (message != null) { + mp.put(IProtoConstant.MP_ERROR_MESSAGE, message); + } + if (errno != null) { + mp.put(IProtoConstant.MP_ERROR_ERRNO, errno); + } + if (errcode != null) { + mp.put(IProtoConstant.MP_ERROR_ERRCODE, errcode); + } + if (fields != null) { + mp.put(IProtoConstant.MP_ERROR_FIELDS, fields); } + return mp.build(); } } diff --git a/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/BaseTarantoolJacksonMapping.java b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/BaseTarantoolJacksonMapping.java index 60047670..214769ff 100644 --- a/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/BaseTarantoolJacksonMapping.java +++ b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/BaseTarantoolJacksonMapping.java @@ -32,6 +32,7 @@ public class BaseTarantoolJacksonMapping { .registerModule(IntervalExtensionModule.INSTANCE) .registerModule(TupleExtensionModule.INSTANCE) .registerModule(UUIDExtensionModule.INSTANCE) + .registerModule(ErrorExtensionModule.INSTANCE) .registerModule(UniversalExtensionModule.INSTANCE); public static final TypeFactory typeFactory = objectMapper.getTypeFactory(); public static final CollectionType LIST_TUPLE = @@ -46,6 +47,7 @@ public class BaseTarantoolJacksonMapping { .registerModule(IntervalExtensionModule.INSTANCE) .registerModule(TupleExtensionModule.INSTANCE) .registerModule(UUIDExtensionModule.INSTANCE) + .registerModule(ErrorExtensionModule.INSTANCE) .registerModule(UniversalExtensionModule.INSTANCE) .registerModule(FormatsModule.INSTANCE); public static final MessagePackFactory innerMpFactory = @@ -57,6 +59,7 @@ public class BaseTarantoolJacksonMapping { .registerModule(UUIDExtensionModule.INSTANCE) .registerModule(DatetimeExtensionModule.INSTANCE) .registerModule(IntervalExtensionModule.INSTANCE) + .registerModule(ErrorExtensionModule.INSTANCE) .registerModule(TupleExtensionModule.INSTANCE) .registerModule(UniversalExtensionModule.INSTANCE); diff --git a/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/ErrorExtensionModule.java b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/ErrorExtensionModule.java new file mode 100644 index 00000000..f8efac8b --- /dev/null +++ b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/ErrorExtensionModule.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.mapping; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import org.msgpack.core.MessageBufferPacker; +import org.msgpack.core.MessagePack; +import org.msgpack.core.MessageUnpacker; +import org.msgpack.jackson.dataformat.MessagePackExtensionType; +import org.msgpack.value.ImmutableValue; + +import io.tarantool.core.exceptions.BoxErrorValue; +import io.tarantool.core.protocol.requests.IProtoConstant; + +public final class ErrorExtensionModule { + + public static final SimpleModule INSTANCE = new SimpleModule("msgpack-ext-error"); + + static { + INSTANCE.addDeserializer(BoxErrorValue.class, new ErrorDeserializer(BoxErrorValue.class)); + INSTANCE.addSerializer(BoxErrorValue.class, new ErrorSerializer(BoxErrorValue.class)); + } + + private ErrorExtensionModule() {} + + public static final class ErrorDeserializer extends StdDeserializer + implements TarantoolDeserializer { + + public ErrorDeserializer(Class cls) { + super(cls); + } + + /* + * +============================+ + * | | + * | 0x52: IPROTO_ERROR | + * | MP_MAP: extra information | + * | | + * +============================+ + * mp_ext 0x03 + * extra information contains only stack of error stack object + */ + @Override + public BoxErrorValue deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + MessagePackExtensionType ext = p.readValueAs(MessagePackExtensionType.class); + + if (ext == null) { + return null; + } + + if (ext.getType() != IProtoConstant.IPROTO_EXT_ERROR) { + throw new JacksonMappingException( + "Unexpected extension type (0x" + + Utils.byteToHex(ext.getType()) + + ") for MP_ERROR object"); + } + + return deserialize(ext); + } + + @Override + public BoxErrorValue deserialize(MessagePackExtensionType ext) throws IOException { + final byte[] data = ext.getData(); + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(data)) { + final ImmutableValue val = unpacker.unpackValue(); + + if (val == null || val.isNilValue()) { + return null; + } + + if (!val.isMapValue()) { + throw new JacksonMappingException("MP_ERROR is not map in root"); + } + return BoxErrorValue.from(val.asMapValue()); + } + } + } + + public static final class ErrorSerializer extends StdSerializer { + + public ErrorSerializer(Class t) { + super(t); + } + + @Override + public void serialize(BoxErrorValue value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (value == null) { + gen.writeObject(null); + return; + } + + final MessagePackExtensionType ext; + try (MessageBufferPacker packer = MessagePack.newDefaultBufferPacker()) { + value.serialize(packer); + ext = new MessagePackExtensionType(IProtoConstant.IPROTO_EXT_ERROR, packer.toByteArray()); + } + gen.writeObject(ext); + } + } +} diff --git a/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/UniversalExtensionModule.java b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/UniversalExtensionModule.java index f634c8d0..4fe5ab7f 100644 --- a/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/UniversalExtensionModule.java +++ b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/UniversalExtensionModule.java @@ -21,9 +21,12 @@ import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_EXT_DATETIME; import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_EXT_DECIMAL; +import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_EXT_ERROR; import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_EXT_INTERVAL; import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_EXT_TUPLE; import static io.tarantool.core.protocol.requests.IProtoConstant.IPROTO_EXT_UUID; +import io.tarantool.core.exceptions.BoxErrorValue; +import io.tarantool.mapping.ErrorExtensionModule.ErrorDeserializer; public class UniversalExtensionModule { @@ -52,6 +55,7 @@ public ObjectDeserializer() { deserializers.put( IPROTO_EXT_INTERVAL, new IntervalExtensionModule.IntervalDeserializer(Interval.class)); deserializers.put(IPROTO_EXT_TUPLE, new TupleExtensionModule.TupleDeserializer()); + deserializers.put(IPROTO_EXT_ERROR, new ErrorDeserializer(BoxErrorValue.class)); } private boolean isExtensionValue(Object object) { @@ -59,10 +63,10 @@ private boolean isExtensionValue(Object object) { } @Override - public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public Object deserialize(JsonParser p, DeserializationContext ctx) throws IOException { if (p.currentTokenId() != JsonTokenId.ID_EMBEDDED_OBJECT || !isExtensionValue(p.getEmbeddedObject())) { - return super.deserialize(p, ctxt); + return super.deserialize(p, ctx); } MessagePackExtensionType ext = p.readValueAs(MessagePackExtensionType.class); byte type = ext.getType(); diff --git a/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/integration/IProtoClientTest.java b/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/integration/IProtoClientTest.java index d62ce309..b036865a 100644 --- a/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/integration/IProtoClientTest.java +++ b/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/integration/IProtoClientTest.java @@ -24,17 +24,21 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.base.Function; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; @@ -51,11 +55,15 @@ import io.tarantool.core.IProtoClient; import io.tarantool.core.IProtoClientImpl; +import io.tarantool.core.exceptions.BoxErrorStackItem; +import io.tarantool.core.exceptions.BoxErrorValue; import io.tarantool.core.protocol.BoxIterator; import io.tarantool.core.protocol.IProtoResponse; +import io.tarantool.core.protocol.requests.IProtoConstant; import io.tarantool.mapping.Adjust; import io.tarantool.mapping.Interval; import io.tarantool.mapping.TarantoolJacksonMapping; +import io.tarantool.mapping.TarantoolResponse; import io.tarantool.mapping.Tuple; import io.tarantool.mapping.entities.TestEntity; @@ -82,6 +90,7 @@ public class IProtoClientTest extends BaseTest { public static final LocalDateTime LOCAL_DATE_TIME = LocalDateTime.parse(T_2022_08_30_05_000000123); public static final long EPOCH_SECOND = LOCAL_DATE_TIME.toEpochSecond(ZoneOffset.UTC); + private static final String LONG_MESSAGE = String.join("", Collections.nCopies(256, "x")); private static int spaceTestId; private static InetSocketAddress address; @@ -1170,4 +1179,310 @@ public void testVarbinaryInAndStringFrom() throws Exception { TarantoolJacksonMapping.readResponse(evalResponse, String.class).get().get(0); assertEquals(object, parsedResponse); } + + public static Stream dataForTestErrorDeserialization() { + return Stream.of( + // The error with the code and the argument. + Arguments.of( + "The error with the code and the argument", + "return box.error.new(box.error.NO_SUCH_SPACE, 'my_space')", + 1, + "ClientError", + "Space 'my_space' does not exist", + 36L), + // The error without the code: the unknown code is used by default. + Arguments.of( + "The error without the code: the unknown code is used by default", + "return box.error.new({reason = 'The error without a code'})", + 1, + "ClientError", + "The error without a code", + 0L), + // The error with the custom payload. + Arguments.of( + "The error with the custom payload", + "return box.error.new({code = 42, reason = 'The custom error'})", + 1, + "ClientError", + "The custom error", + 42L), + // The plain Lua error is a string, not a box.error object: it must be wrapped + // into the box.error object to be returned as the MP_ERROR value. + Arguments.of( + "The Lua error, caught by pcall", + "local ok, err = pcall(fail) return box.error.new({reason = tostring(err)})", + 1, + "ClientError", + "Fail!", + 0L), + // The error with the chain of two errors. + Arguments.of( + "The error with the chain of two errors", + "local ok, err = pcall(wrapped_fail_by_box_error) return err", + 2, + "ClientError", + "wrapped failure", + 0L), + // The error in the eval script itself. The plain Lua error is a string, not a + // box.error object: it must be wrapped into the box.error object to be returned + // as the MP_ERROR value. + Arguments.of( + "The error in the eval script itself", + "local x = nil local ok, err = pcall(function() return x.field end) " + + "return box.error.new({reason = tostring(err)})", + 1, + "ClientError", + "attempt to index", + 0L)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("dataForTestErrorDeserialization") + void testErrorDeserialization( + String label, + String expression, + int expectedStackSize, + String expectedType, + String expectedMessage, + long expectedCode) + throws Exception { + final IProtoClient client = getClientAndConnect(); + final IProtoResponse response = client.eval(expression, ValueFactory.emptyArray()).join(); + + final TarantoolResponse> result = + TarantoolJacksonMapping.readResponse(response, BoxErrorValue.class); + final BoxErrorValue errorValue = result.get().get(0); + + Assertions.assertEquals(expectedStackSize, errorValue.stack().size()); + + final BoxErrorStackItem item = errorValue.stack().get(0); + Assertions.assertEquals(expectedType, item.type()); + // The Lua error message contains the position prefix, so it is compared partially. + Assertions.assertTrue( + item.message().contains(expectedMessage), + "Expected the message to contain '" + expectedMessage + "', but got: " + item.message()); + Assertions.assertEquals(expectedCode, item.code()); + Assertions.assertEquals(0L, item.errNo()); + + client.close(); + } + + // The second element of the chain must not be lost. + @Test + @DisplayName("Check wrapped errors") + void testErrorDeserializationStack() throws Exception { + final IProtoClient client = getClientAndConnect(); + try { + final IProtoResponse response = + client + .eval( + "local ok, err = pcall(wrapped_fail_by_box_error) return err", + ValueFactory.emptyArray()) + .join(); + + final BoxErrorValue errorValue = + TarantoolJacksonMapping.readResponse(response, BoxErrorValue.class).get().get(0); + Assertions.assertEquals(2, errorValue.stack().size()); + Assertions.assertEquals("wrapped failure", errorValue.stack().get(0).message()); + Assertions.assertEquals("fail", errorValue.stack().get(1).message()); + } finally { + client.close(); + } + } + + // The error inside an array and inside a map. + @Test + void testErrorDeserializationInContainer() throws Exception { + final IProtoClient client = getClientAndConnect(); + try { + // The error is the first element of the returned array. + final IProtoResponse arrayResponse = + client + .eval( + "return {box.error.new(box.error.NO_SUCH_SPACE, 'my_space'), 42}", + ValueFactory.emptyArray()) + .join(); + + final List arrayResult = TarantoolJacksonMapping.readResponse(arrayResponse).get(); + final List tuple = (List) arrayResult.get(0); + final BoxErrorValue errorValue = assertInstanceOf(BoxErrorValue.class, tuple.get(0)); + Assertions.assertEquals( + "Space 'my_space' does not exist", errorValue.stack().get(0).message()); + + // The error is the value of the 'err' key of the returned map. + final IProtoResponse mapResponse = + client + .eval( + "return {err = box.error.new(box.error.NO_SUCH_SPACE, 'my_space')}", + ValueFactory.emptyArray()) + .join(); + + final List mapResult = TarantoolJacksonMapping.readResponse(mapResponse).get(); + Map map = assertInstanceOf(Map.class, mapResult.get(0)); + final BoxErrorValue err = assertInstanceOf(BoxErrorValue.class, map.get("err")); + Assertions.assertEquals("Space 'my_space' does not exist", err.stack().get(0).message()); + } finally { + client.close(); + } + } + + @Test + void testNilErrorDeserialization() throws Exception { + final IProtoClient client = getClientAndConnect(); + try { + final IProtoResponse response = client.eval("return nil", ValueFactory.emptyArray()).join(); + final List errors = + TarantoolJacksonMapping.readResponse(response, BoxErrorValue.class).get(); + Assertions.assertEquals(1, errors.size()); + Assertions.assertNull(errors.get(0)); + } finally { + client.close(); + } + } + + public static Stream dataForTestErrorSerialization() { + return Stream.of( + // All the fields have their default values. + Arguments.of( + new BoxErrorValue(Collections.singletonList(BoxErrorStackItem.builder().build())), + "all the fields are default"), + // All the fields are set. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder() + .withType("ClientError") + .withLine(42L) + .withFile("test.lua") + .withMessage("Something went wrong") + .withErrNo(7L) + .withCode(32L) + .build())), + "all the fields are set"), + // Only the type and the message are set, the other fields have their default values. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder() + .withType("ClientError") + .withMessage("Only the type and the message are set") + .build())), + "only the type and the message are set"), + // Only the message is set, the other fields have their default values. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder().withMessage("Only the message is set").build())), + "only the message is set"), + // The additional fields are set to an empty map. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder() + .withType("ClientError") + .withMessage("The additional fields are empty") + .withFields(ValueFactory.emptyMap()) + .build())), + "the additional fields are an empty map"), + // The additional fields are set to a non-empty map. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder() + .withType("AccessDeniedError") + .withMessage("The additional fields are not empty") + .withFields( + ValueFactory.newMap( + ValueFactory.newString("object_type"), + ValueFactory.newString("space"), + ValueFactory.newString("object_name"), + ValueFactory.newString("my_space"), + ValueFactory.newString("access_type"), + ValueFactory.newString("write"))) + .build())), + "the additional fields are a non-empty map"), + // The stack consists of two errors. + Arguments.of( + new BoxErrorValue( + Arrays.asList( + BoxErrorStackItem.builder() + .withType("ClientError") + .withMessage("The first error of the stack") + .build(), + BoxErrorStackItem.builder() + .withType("LuajitError") + .withMessage("The second error of the stack") + .build())), + "the stack consists of two errors"), + // The message contains special characters. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder() + .withMessage("\"quotes\", \n newline, \\ backslash, \t tab") + .build())), + "the message contains special characters"), + // The numeric fields have the maximum values. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder() + .withLine(Integer.MAX_VALUE) + .withErrNo(Integer.MAX_VALUE) + .withCode(Integer.MAX_VALUE) + .withMessage("The maximum numeric values") + .build())), + "the numeric fields have the maximum values"), + // The message is long. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.builder().withMessage(LONG_MESSAGE).build())), + "the message is long"), + // The error object is built by parsing the MP_ERROR structure. + Arguments.of( + new BoxErrorValue( + Collections.singletonList( + BoxErrorStackItem.fromStruct( + ValueFactory.newMap( + IProtoConstant.MP_ERROR_TYPE, + ValueFactory.newString("ClientError"), + IProtoConstant.MP_ERROR_FILE, + ValueFactory.newString("test.lua"), + IProtoConstant.MP_ERROR_LINE, + ValueFactory.newInteger(42L), + IProtoConstant.MP_ERROR_MESSAGE, + ValueFactory.newString("The parsed error"), + IProtoConstant.MP_ERROR_ERRNO, + ValueFactory.newInteger(7L), + IProtoConstant.MP_ERROR_ERRCODE, + ValueFactory.newInteger(32L))))), + "the error object is parsed from the MP_ERROR structure")); + } + + @ParameterizedTest(name = "{1}") + @MethodSource("dataForTestErrorSerialization") + void testErrorSerialization(BoxErrorValue errorValue, String caseName) throws Exception { + final IProtoClient client = getClientAndConnect(); + try { + final byte[] value = TarantoolJacksonMapping.toValue(Collections.singletonList(errorValue)); + final CompletableFuture call = client.call("error_verify", value); + final IProtoResponse response = call.join(); + + final List result = TarantoolJacksonMapping.readResponse(response).get(); + final Object check = result.get(0); + final Map checkMap = + Assertions.assertInstanceOf( + Map.class, check, String.format("Unexpected result: %s", check)); + + Assertions.assertEquals( + Boolean.TRUE, + checkMap.get("ok"), + String.format( + "The case '%s' has failed: %s (%s)", + caseName, checkMap.get("reason"), checkMap.get("raw_value"))); + } finally { + client.close(); + } + } } diff --git a/tarantool-shared-resources/server.lua b/tarantool-shared-resources/server.lua index 9d561afc..b15b42fe 100644 --- a/tarantool-shared-resources/server.lua +++ b/tarantool-shared-resources/server.lua @@ -231,6 +231,24 @@ function wrapped_fail_by_box_error() box.error(exception) end +function error_verify(err) + local ok = pcall(function() return err:unpack() end) + + if not ok then + return { + ok = false, + reason = 'Expected box.error object, got: ' .. type(err), + raw_value = tostring(err) + } + else + return { + ok = true, + reason = nil, + raw_value = nil + } + end +end + function echo_with_push(...) box.session.push({ "push1", "out of band" }) box.session.push({ "push2", "out of band" }) diff --git a/tarantool-shared-resources/tarantool3/init.lua b/tarantool-shared-resources/tarantool3/init.lua index b0477076..fbb4b3d2 100644 --- a/tarantool-shared-resources/tarantool3/init.lua +++ b/tarantool-shared-resources/tarantool3/init.lua @@ -207,6 +207,24 @@ function wrapped_fail_by_box_error() box.error(exception) end +function error_verify(err) + local ok = pcall(function() return err:unpack() end) + + if not ok then + return { + ok = false, + reason = 'Expected box.error object, got: ' .. type(err), + raw_value = tostring(err) + } + else + return { + ok = true, + reason = nil, + raw_value = nil + } + end +end + function echo_with_push(...) box.session.push({ "push1", "out of band" }) box.session.push({ "push2", "out of band" })