From 319c93be53a7c75ecf0b4d5d6f72f7179511b9e4 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Mon, 28 Nov 2022 17:02:32 +0300 Subject: [PATCH] api: support error type in MessagePack Tarantool supports error extension type since version 2.4.1 [1], encoding was introduced in Tarantool 2.10.0 [2]. This patch introduces the support of Tarantool error extension type in msgpack decoders and encoders. Tarantool error extension type objects are decoded to `*tarantool.BoxError` type. `*tarantool.BoxError` may be encoded to Tarantool error extension type objects. Error extension type internals are the same as errors extended information: the only difference is that extra information is encoded as a separate error dictionary field and error extension type objects are encoded as MessagePack extension type objects. The only way to receive an error extension type object from Tarantool is to receive an explicitly built `box.error` object: either from `return box.error.new(...)` or a tuple with it. All errors raised within Tarantool (including those raised with `box.error(...)`) are encoded based on the same rules as simple errors due to backward compatibility. It is possible to create error extension type objects with Go code, but it not likely to be really useful since most of their fields is computed on error initialization on the server side (even for custom error types). This patch also adds ErrorExtensionFeature flag to client protocol features list. Without this flag, all `box.error` object sent over iproto are encoded to string. We behave like Tarantool `net.box` here: if we support the feature, we provide the feature flag. Since it may become too complicated to enable/disable feature flag through import, error extension type is available as a part of the base package, in contrary to Decimal, UUID, Datetime and Interval types which are enabled by importing underscore subpackage. 1. tarantool/tarantool#4398 2. tarantool/tarantool#6433 Closes #209 --- CHANGELOG.md | 1 + box_error.go | 113 +++++++++++++++ box_error_test.go | 293 ++++++++++++++++++++++++++++++++++++++ config.lua | 67 +++++++++ example_test.go | 2 +- msgpack.go | 4 + msgpack_helper_test.go | 14 ++ msgpack_v5.go | 4 + msgpack_v5_helper_test.go | 17 +++ protocol.go | 7 +- tarantool_test.go | 16 ++- test_helpers/utils.go | 17 +++ 12 files changed, 548 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e870c6a9d..0585e129f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release. - Support iproto feature discovery (#120) - Support errors extended information (#209) +- Error type support in MessagePack (#209) ### Changed diff --git a/box_error.go b/box_error.go index 97c5c1774..ab8981b0b 100644 --- a/box_error.go +++ b/box_error.go @@ -5,6 +5,8 @@ import ( "fmt" ) +const errorExtID = 3 + const ( keyErrorStack = 0x00 keyErrorType = 0x00 @@ -167,6 +169,105 @@ func decodeBoxError(d *decoder) (*BoxError, error) { return &errorStack[0], nil } +func encodeBoxError(enc *encoder, boxError *BoxError) error { + if boxError == nil { + return fmt.Errorf("msgpack: unexpected nil BoxError on encode") + } + + if err := enc.EncodeMapLen(1); err != nil { + return err + } + if err := encodeUint(enc, keyErrorStack); err != nil { + return err + } + + var stackDepth = boxError.Depth() + if err := enc.EncodeArrayLen(stackDepth); err != nil { + return err + } + + for ; stackDepth > 0; stackDepth-- { + fieldsLen := len(boxError.Fields) + + if fieldsLen > 0 { + if err := enc.EncodeMapLen(7); err != nil { + return err + } + } else { + if err := enc.EncodeMapLen(6); err != nil { + return err + } + } + + if err := encodeUint(enc, keyErrorType); err != nil { + return err + } + if err := enc.EncodeString(boxError.Type); err != nil { + return err + } + + if err := encodeUint(enc, keyErrorFile); err != nil { + return err + } + if err := enc.EncodeString(boxError.File); err != nil { + return err + } + + if err := encodeUint(enc, keyErrorLine); err != nil { + return err + } + if err := enc.EncodeUint64(boxError.Line); err != nil { + return err + } + + if err := encodeUint(enc, keyErrorMessage); err != nil { + return err + } + if err := enc.EncodeString(boxError.Msg); err != nil { + return err + } + + if err := encodeUint(enc, keyErrorErrno); err != nil { + return err + } + if err := enc.EncodeUint64(boxError.Errno); err != nil { + return err + } + + if err := encodeUint(enc, keyErrorErrcode); err != nil { + return err + } + if err := enc.EncodeUint64(boxError.Code); err != nil { + return err + } + + if fieldsLen > 0 { + if err := encodeUint(enc, keyErrorFields); err != nil { + return err + } + + if err := enc.EncodeMapLen(fieldsLen); err != nil { + return err + } + + for k, v := range boxError.Fields { + if err := enc.EncodeString(k); err != nil { + return err + } + if err := enc.Encode(v); err != nil { + return err + } + } + } + + if stackDepth > 1 { + boxError = boxError.Prev + } + } + + return nil +} + // UnmarshalMsgpack deserializes a BoxError value from a MessagePack // representation. func (e *BoxError) UnmarshalMsgpack(b []byte) error { @@ -184,3 +285,15 @@ func (e *BoxError) UnmarshalMsgpack(b []byte) error { return nil } } + +// MarshalMsgpack serializes the BoxError into a MessagePack representation. +func (e *BoxError) MarshalMsgpack() ([]byte, error) { + var buf bytes.Buffer + + enc := newEncoder(&buf) + if err := encodeBoxError(enc, e); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/box_error_test.go b/box_error_test.go index 76ba3290d..276ca2cf8 100644 --- a/box_error_test.go +++ b/box_error_test.go @@ -1,11 +1,13 @@ package tarantool_test import ( + "fmt" "regexp" "testing" "github.com/stretchr/testify/require" . "github.com/tarantool/go-tarantool" + "github.com/tarantool/go-tarantool/test_helpers" ) var samples = map[string]BoxError{ @@ -198,3 +200,294 @@ func TestMessagePackUnmarshalToNil(t *testing.T) { require.PanicsWithValue(t, "cannot unmarshal to a nil pointer", func() { val.UnmarshalMsgpack(mpDecodeSamples["InnerMapExtraKey"].b) }) } + +func TestMessagePackEncodeNil(t *testing.T) { + var val *BoxError + + _, err := val.MarshalMsgpack() + require.NotNil(t, err) + require.Equal(t, "msgpack: unexpected nil BoxError on encode", err.Error()) +} + +var space = "test_error_type" +var index = "primary" + +type TupleBoxError struct { + pk string // BoxError cannot be used as a primary key. + val BoxError +} + +func (t *TupleBoxError) EncodeMsgpack(e *encoder) error { + if err := e.EncodeArrayLen(2); err != nil { + return err + } + + if err := e.EncodeString(t.pk); err != nil { + return err + } + + return e.Encode(&t.val) +} + +func (t *TupleBoxError) DecodeMsgpack(d *decoder) error { + var err error + var l int + if l, err = d.DecodeArrayLen(); err != nil { + return err + } + if l != 2 { + return fmt.Errorf("Array length doesn't match: %d", l) + } + + if t.pk, err = d.DecodeString(); err != nil { + return err + } + + return d.Decode(&t.val) +} + +// Raw bytes encoding test is impossible for +// object with Fields since map iterating is random. +var tupleCases = map[string]struct { + tuple TupleBoxError + ttObj string +}{ + "SimpleError": { + TupleBoxError{ + "simple_error_pk", + samples["SimpleError"], + }, + "simple_error", + }, + "AccessDeniedError": { + TupleBoxError{ + "access_denied_error_pk", + samples["AccessDeniedError"], + }, + "access_denied_error", + }, + "ChainedError": { + TupleBoxError{ + "chained_error_pk", + samples["ChainedError"], + }, + "chained_error", + }, +} + +func TestErrorTypeMPEncodeDecode(t *testing.T) { + for name, testcase := range tupleCases { + t.Run(name, func(t *testing.T) { + buf, err := marshal(&testcase.tuple) + require.Nil(t, err) + + var res TupleBoxError + err = unmarshal(buf, &res) + require.Nil(t, err) + + require.Equal(t, testcase.tuple, res) + }) + } +} + +func TestErrorTypeEval(t *testing.T) { + test_helpers.SkipIfErrorMessagePackTypeUnsupported(t) + + conn := test_helpers.ConnectWithValidation(t, server, opts) + defer conn.Close() + + for name, testcase := range tupleCases { + t.Run(name, func(t *testing.T) { + resp, err := conn.Eval("return ...", []interface{}{&testcase.tuple.val}) + require.Nil(t, err) + require.NotNil(t, resp.Data) + require.Equal(t, len(resp.Data), 1) + actual, ok := toBoxError(resp.Data[0]) + require.Truef(t, ok, "Response data has valid type") + require.Equal(t, testcase.tuple.val, actual) + }) + } +} + +func TestErrorTypeEvalTyped(t *testing.T) { + test_helpers.SkipIfErrorMessagePackTypeUnsupported(t) + + conn := test_helpers.ConnectWithValidation(t, server, opts) + defer conn.Close() + + for name, testcase := range tupleCases { + t.Run(name, func(t *testing.T) { + var res []BoxError + err := conn.EvalTyped("return ...", []interface{}{&testcase.tuple.val}, &res) + require.Nil(t, err) + require.NotNil(t, res) + require.Equal(t, len(res), 1) + require.Equal(t, testcase.tuple.val, res[0]) + }) + } +} + +func TestErrorTypeInsert(t *testing.T) { + test_helpers.SkipIfErrorMessagePackTypeUnsupported(t) + + conn := test_helpers.ConnectWithValidation(t, server, opts) + defer conn.Close() + + truncateEval := fmt.Sprintf("box.space[%q]:truncate()", space) + _, err := conn.Eval(truncateEval, []interface{}{}) + require.Nil(t, err) + + for name, testcase := range tupleCases { + t.Run(name, func(t *testing.T) { + _, err = conn.Insert(space, &testcase.tuple) + require.Nil(t, err) + + checkEval := fmt.Sprintf(` + local err = rawget(_G, %q) + assert(err ~= nil) + + local tuple = box.space[%q]:get(%q) + assert(tuple ~= nil) + + local tuple_err = tuple[2] + assert(tuple_err ~= nil) + + return compare_box_errors(err, tuple_err) + `, testcase.ttObj, space, testcase.tuple.pk) + + // In fact, compare_box_errors does not check than File and Line + // of connector BoxError are equal to the Tarantool ones + // since they may differ between different Tarantool versions + // and editions. + _, err := conn.Eval(checkEval, []interface{}{}) + require.Nilf(t, err, "Tuple has been successfully inserted") + }) + } +} + +func TestErrorTypeInsertTyped(t *testing.T) { + test_helpers.SkipIfErrorMessagePackTypeUnsupported(t) + + conn := test_helpers.ConnectWithValidation(t, server, opts) + defer conn.Close() + + truncateEval := fmt.Sprintf("box.space[%q]:truncate()", space) + _, err := conn.Eval(truncateEval, []interface{}{}) + require.Nil(t, err) + + for name, testcase := range tupleCases { + t.Run(name, func(t *testing.T) { + var res []TupleBoxError + err = conn.InsertTyped(space, &testcase.tuple, &res) + require.Nil(t, err) + require.NotNil(t, res) + require.Equal(t, len(res), 1) + require.Equal(t, testcase.tuple, res[0]) + + checkEval := fmt.Sprintf(` + local err = rawget(_G, %q) + assert(err ~= nil) + + local tuple = box.space[%q]:get(%q) + assert(tuple ~= nil) + + local tuple_err = tuple[2] + assert(tuple_err ~= nil) + + return compare_box_errors(err, tuple_err) + `, testcase.ttObj, space, testcase.tuple.pk) + + // In fact, compare_box_errors does not check than File and Line + // of connector BoxError are equal to the Tarantool ones + // since they may differ between different Tarantool versions + // and editions. + _, err := conn.Eval(checkEval, []interface{}{}) + require.Nilf(t, err, "Tuple has been successfully inserted") + }) + } +} + +func TestErrorTypeSelect(t *testing.T) { + test_helpers.SkipIfErrorMessagePackTypeUnsupported(t) + + conn := test_helpers.ConnectWithValidation(t, server, opts) + defer conn.Close() + + truncateEval := fmt.Sprintf("box.space[%q]:truncate()", space) + _, err := conn.Eval(truncateEval, []interface{}{}) + require.Nil(t, err) + + for name, testcase := range tupleCases { + t.Run(name, func(t *testing.T) { + insertEval := fmt.Sprintf(` + local err = rawget(_G, %q) + assert(err ~= nil) + + local tuple = box.space[%q]:insert{%q, err} + assert(tuple ~= nil) + `, testcase.ttObj, space, testcase.tuple.pk) + + _, err := conn.Eval(insertEval, []interface{}{}) + require.Nilf(t, err, "Tuple has been successfully inserted") + + var resp *Response + var offset uint32 = 0 + var limit uint32 = 1 + resp, err = conn.Select(space, index, offset, limit, IterEq, []interface{}{testcase.tuple.pk}) + require.Nil(t, err) + require.NotNil(t, resp.Data) + require.Equalf(t, len(resp.Data), 1, "Exactly one tuple had been found") + tpl, ok := resp.Data[0].([]interface{}) + require.Truef(t, ok, "Tuple has valid type") + require.Equal(t, testcase.tuple.pk, tpl[0]) + var actual BoxError + actual, ok = toBoxError(tpl[1]) + require.Truef(t, ok, "BoxError tuple field has valid type") + // In fact, CheckEqualBoxErrors does not check than File and Line + // of connector BoxError are equal to the Tarantool ones + // since they may differ between different Tarantool versions + // and editions. + test_helpers.CheckEqualBoxErrors(t, testcase.tuple.val, actual) + }) + } +} + +func TestErrorTypeSelectTyped(t *testing.T) { + test_helpers.SkipIfErrorMessagePackTypeUnsupported(t) + + conn := test_helpers.ConnectWithValidation(t, server, opts) + defer conn.Close() + + truncateEval := fmt.Sprintf("box.space[%q]:truncate()", space) + _, err := conn.Eval(truncateEval, []interface{}{}) + require.Nil(t, err) + + for name, testcase := range tupleCases { + t.Run(name, func(t *testing.T) { + insertEval := fmt.Sprintf(` + local err = rawget(_G, %q) + assert(err ~= nil) + + local tuple = box.space[%q]:insert{%q, err} + assert(tuple ~= nil) + `, testcase.ttObj, space, testcase.tuple.pk) + + _, err := conn.Eval(insertEval, []interface{}{}) + require.Nilf(t, err, "Tuple has been successfully inserted") + + var offset uint32 = 0 + var limit uint32 = 1 + var resp []TupleBoxError + err = conn.SelectTyped(space, index, offset, limit, IterEq, []interface{}{testcase.tuple.pk}, &resp) + require.Nil(t, err) + require.NotNil(t, resp) + require.Equalf(t, len(resp), 1, "Exactly one tuple had been found") + require.Equal(t, testcase.tuple.pk, resp[0].pk) + // In fact, CheckEqualBoxErrors does not check than File and Line + // of connector BoxError are equal to the Tarantool ones + // since they may differ between different Tarantool versions + // and editions. + test_helpers.CheckEqualBoxErrors(t, testcase.tuple.val, resp[0].val) + }) + } +} diff --git a/config.lua b/config.lua index c2d52d209..bb976ef43 100644 --- a/config.lua +++ b/config.lua @@ -116,6 +116,21 @@ box.once("init", function() } end + local s = box.schema.space.create('test_error_type', { + id = 522, + temporary = true, + if_not_exists = true, + field_count = 2, + -- You can't specify box.error as format type, + -- but can put box.error objects. + }) + s:create_index('primary', { + type = 'tree', + unique = true, + parts = {1, 'string'}, + if_not_exists = true + }) + --box.schema.user.grant('guest', 'read,write,execute', 'universe') box.schema.func.create('box.info') box.schema.func.create('simple_concat') @@ -126,6 +141,7 @@ box.once("init", function() box.schema.user.grant('test', 'read,write', 'space', 'test') box.schema.user.grant('test', 'read,write', 'space', 'schematest') box.schema.user.grant('test', 'read,write', 'space', 'test_perf') + box.schema.user.grant('test', 'read,write', 'space', 'test_error_type') -- grants for sql tests box.schema.user.grant('test', 'create,read,write,drop,alter', 'space') @@ -182,6 +198,8 @@ end if tarantool_version_at_least(2, 4, 1) then local e1 = box.error.new(box.error.UNKNOWN) + rawset(_G, 'simple_error', e1) + local e2 = box.error.new(box.error.TIMEOUT) e2:set_prev(e1) rawset(_G, 'chained_error', e2) @@ -192,6 +210,55 @@ if tarantool_version_at_least(2, 4, 1) then local _, access_denied_error = pcall(function() box.func.forbidden_function:call() end) box.session.su(user) rawset(_G, 'access_denied_error', access_denied_error) + + -- cdata structure is as follows: + -- + -- tarantool> err:unpack() + -- - code: val + -- base_type: val + -- type: val + -- message: val + -- field1: val + -- field2: val + -- trace: + -- - file: val + -- line: val + + local function compare_box_error_attributes(expected, actual, attr_provider) + for attr, _ in pairs(attr_provider:unpack()) do + if (attr ~= 'prev') and (attr ~= 'trace') then + if expected[attr] ~= actual[attr] then + error(('%s expected %s is not equal to actual %s'):format( + attr, expected[attr], actual[attr])) + end + end + end + end + + local function compare_box_errors(expected, actual) + if (expected == nil) and (actual ~= nil) then + error(('Expected error stack is empty, but actual error ' .. + 'has previous %s (%s) error'):format( + actual.type, actual.message)) + end + + if (expected ~= nil) and (actual == nil) then + error(('Actual error stack is empty, but expected error ' .. + 'has previous %s (%s) error'):format( + expected.type, expected.message)) + end + + compare_box_error_attributes(expected, actual, expected) + compare_box_error_attributes(expected, actual, actual) + + if (expected.prev ~= nil) or (actual.prev ~= nil) then + return compare_box_errors(expected.prev, actual.prev) + end + + return true + end + + rawset(_G, 'compare_box_errors', compare_box_errors) end box.space.test:truncate() diff --git a/example_test.go b/example_test.go index 15574d099..54202eb46 100644 --- a/example_test.go +++ b/example_test.go @@ -329,7 +329,7 @@ func ExampleProtocolVersion() { fmt.Println("Connector client protocol features:", clientProtocolInfo.Features) // Output: // Connector client protocol version: 4 - // Connector client protocol features: [StreamsFeature TransactionsFeature] + // Connector client protocol features: [StreamsFeature TransactionsFeature ErrorExtensionFeature] } func getTestTxnOpts() tarantool.Opts { diff --git a/msgpack.go b/msgpack.go index 34ecc4b3b..9977e9399 100644 --- a/msgpack.go +++ b/msgpack.go @@ -48,3 +48,7 @@ func msgpackIsString(code byte) bool { return msgpcode.IsFixedString(code) || code == msgpcode.Str8 || code == msgpcode.Str16 || code == msgpcode.Str32 } + +func init() { + msgpack.RegisterExt(errorExtID, &BoxError{}) +} diff --git a/msgpack_helper_test.go b/msgpack_helper_test.go index fa47c2fda..896c105d3 100644 --- a/msgpack_helper_test.go +++ b/msgpack_helper_test.go @@ -4,6 +4,7 @@ package tarantool_test import ( + "github.com/tarantool/go-tarantool" "gopkg.in/vmihailenco/msgpack.v2" ) @@ -13,3 +14,16 @@ type decoder = msgpack.Decoder func encodeUint(e *encoder, v uint64) error { return e.EncodeUint(uint(v)) } + +func toBoxError(i interface{}) (v tarantool.BoxError, ok bool) { + v, ok = i.(tarantool.BoxError) + return +} + +func marshal(v interface{}) ([]byte, error) { + return msgpack.Marshal(v) +} + +func unmarshal(data []byte, v interface{}) error { + return msgpack.Unmarshal(data, v) +} diff --git a/msgpack_v5.go b/msgpack_v5.go index 806dd1632..e8cd9aa29 100644 --- a/msgpack_v5.go +++ b/msgpack_v5.go @@ -52,3 +52,7 @@ func msgpackIsString(code byte) bool { return msgpcode.IsFixedString(code) || code == msgpcode.Str8 || code == msgpcode.Str16 || code == msgpcode.Str32 } + +func init() { + msgpack.RegisterExt(errorExtID, (*BoxError)(nil)) +} diff --git a/msgpack_v5_helper_test.go b/msgpack_v5_helper_test.go index 347c1ba95..88154c26f 100644 --- a/msgpack_v5_helper_test.go +++ b/msgpack_v5_helper_test.go @@ -4,6 +4,7 @@ package tarantool_test import ( + "github.com/tarantool/go-tarantool" "github.com/vmihailenco/msgpack/v5" ) @@ -13,3 +14,19 @@ type decoder = msgpack.Decoder func encodeUint(e *encoder, v uint64) error { return e.EncodeUint(v) } + +func toBoxError(i interface{}) (v tarantool.BoxError, ok bool) { + var ptr *tarantool.BoxError + if ptr, ok = i.(*tarantool.BoxError); ok { + v = *ptr + } + return +} + +func marshal(v interface{}) ([]byte, error) { + return msgpack.Marshal(v) +} + +func unmarshal(data []byte, v interface{}) error { + return msgpack.Unmarshal(data, v) +} diff --git a/protocol.go b/protocol.go index 1eaf60e2b..fa890fdd4 100644 --- a/protocol.go +++ b/protocol.go @@ -42,7 +42,7 @@ const ( // (unsupported by connector). ErrorExtensionFeature ProtocolFeature = 2 // WatchersFeature represents support of watchers - // (unsupported by connector). + // (supported by connector). WatchersFeature ProtocolFeature = 3 // PaginationFeature represents support of pagination // (unsupported by connector). @@ -76,10 +76,13 @@ var clientProtocolInfo ProtocolInfo = ProtocolInfo{ // 1.10.0. Version: ProtocolVersion(4), // Streams and transactions were introduced in protocol version 1 - // (Tarantool 2.10.0), in connector since 1.7.0. + // (Tarantool 2.10.0), in connector since 1.7.0. Error extension + // type was introduced in protocol version 2 (Tarantool 2.10.0), + // in connector since 1.10.0. Features: []ProtocolFeature{ StreamsFeature, TransactionsFeature, + ErrorExtensionFeature, }, } diff --git a/tarantool_test.go b/tarantool_test.go index 0accac5e7..202c10b17 100644 --- a/tarantool_test.go +++ b/tarantool_test.go @@ -2868,8 +2868,12 @@ func TestConnectionProtocolInfoSupported(t *testing.T) { require.Equal(t, clientProtocolInfo, ProtocolInfo{ - Version: ProtocolVersion(4), - Features: []ProtocolFeature{StreamsFeature, TransactionsFeature}, + Version: ProtocolVersion(4), + Features: []ProtocolFeature{ + StreamsFeature, + TransactionsFeature, + ErrorExtensionFeature, + }, }) serverProtocolInfo := conn.ServerProtocolInfo() @@ -2997,8 +3001,12 @@ func TestConnectionProtocolInfoUnsupported(t *testing.T) { require.Equal(t, clientProtocolInfo, ProtocolInfo{ - Version: ProtocolVersion(4), - Features: []ProtocolFeature{StreamsFeature, TransactionsFeature}, + Version: ProtocolVersion(4), + Features: []ProtocolFeature{ + StreamsFeature, + TransactionsFeature, + ErrorExtensionFeature, + }, }) serverProtocolInfo := conn.ServerProtocolInfo() diff --git a/test_helpers/utils.go b/test_helpers/utils.go index be25b5804..5081de6c2 100644 --- a/test_helpers/utils.go +++ b/test_helpers/utils.go @@ -176,3 +176,20 @@ func SkipIfErrorExtendedInfoUnsupported(t *testing.T) { t.Skip("Skipping test for Tarantool without error extended info support") } } + +// SkipIfErrorExtendedInfoUnsupported skips test run if Tarantool without +// MP_ERROR type over iproto support is used. +func SkipIfErrorMessagePackTypeUnsupported(t *testing.T) { + t.Helper() + + // Tarantool error type over MessagePack supported only since 2.10.0 version. + isLess, err := IsTarantoolVersionLess(2, 10, 0) + if err != nil { + t.Fatalf("Could not check the Tarantool version") + } + + if isLess { + t.Skip("Skipping test for Tarantool without support of error type over MessagePack") + t.Skip("Skipping test for Tarantool without error extended info support") + } +}