-- proto3 JSON mapping tests. local t = require('luatest') local ffi = require('ffi') local pb = require('pb') local json = require('json') local hello = require('full.hello.hello_pb') local function reparse(s) return json.decode(s) end local g = t.group('json.scalars') g.test_basic_round_trip = function() local p = {name = 'Alice', age = 30} local enc = pb.json.encode(hello.Person_descriptor, p) local obj = reparse(enc) t.assert_equals(obj.name, 'Alice') t.assert_equals(obj.age, 30) end g.test_proto3_default_elision = function() -- Defaults are elided from JSON output unless presence is meaningful. local enc = pb.json.encode(hello.Person_descriptor, {age = 0, name = ''}) t.assert_equals(reparse(enc), setmetatable({}, getmetatable(reparse('{}')))) end g.test_camel_case_field_names = function() local enc = pb.json.encode(hello.Person_descriptor, {user_id = ffi.cast('uint64_t', 1234567890123)}) t.assert_str_contains(enc, '"userId"', 'field emitted in camelCase') end g.test_int64_as_string = function() local p = {user_id = ffi.cast('uint64_t', 12345678901234567890ULL)} local obj = reparse(pb.json.encode(hello.Person_descriptor, p)) t.assert_equals(obj.userId, '12345678901234567890', 'uint64 stringified per spec') end g.test_bytes_base64 = function() local p = {avatar = '\x00\x01\xff'} local obj = reparse(pb.json.encode(hello.Person_descriptor, p)) t.assert_equals(obj.avatar, 'AAH/') -- Round-trip local p2 = pb.json.decode(hello.Person_descriptor, pb.json.encode(hello.Person_descriptor, p)) t.assert_equals(p2.avatar, p.avatar) end g.test_bytes_base64_unwrapped_long_payload = function() -- Canonical proto3 JSON requires RFC 4648 base64 with no line wrapping. -- Tarantool's digest.base64_encode defaults to MIME-style 76-char wrap, -- so any payload past ~57 bytes used to emit a `\n` inside the JSON -- string and break grpc-gateway / protojson consumers. local p = {avatar = string.rep('A', 64)} local enc = pb.json.encode(hello.Person_descriptor, p) t.assert_not_str_contains(enc, '\n', 'no raw newline anywhere in JSON output') t.assert_not_str_contains(enc, '\\n', 'no escaped newline in base64 token') local obj = reparse(enc) t.assert_not_str_contains(obj.avatar, '\n', 'base64 token is a single line') local p2 = pb.json.decode(hello.Person_descriptor, enc) t.assert_equals(p2.avatar, p.avatar) end g.test_repeated_packed_scalar = function() local p = {lucky_numbers = {1, 2, 3}} local obj = reparse(pb.json.encode(hello.Person_descriptor, p)) t.assert_equals(obj.luckyNumbers, {1, 2, 3}) end g.test_repeated_string = function() local p = {emails = {'a@x', 'b@x'}} local obj = reparse(pb.json.encode(hello.Person_descriptor, p)) t.assert_equals(obj.emails, {'a@x', 'b@x'}) end g.test_enum_as_name = function() local enc = pb.json.encode(hello.Person_descriptor, {status = hello.Status.ERROR}) t.assert_str_contains(enc, '"status":"ERROR"') end g.test_enum_string_input_accepted_on_decode = function() local p = pb.json.decode(hello.Person_descriptor, '{"status":"OK"}') t.assert_equals(p.status, hello.Status.OK) end g.test_snake_case_input_also_accepted = function() local p = pb.json.decode(hello.Person_descriptor, '{"user_id":"99"}') t.assert_equals(tonumber(p.user_id), 99) end g.test_nested_message_round_trip = function() local p = {name = 'P', address = {street = 'X', zip = 1}} local enc = pb.json.encode(hello.Person_descriptor, p) local back = pb.json.decode(hello.Person_descriptor, enc) t.assert_equals(back.name, 'P') t.assert_equals(back.address.street, 'X') t.assert_equals(back.address.zip, 1) end g.test_map_round_trip = function() local p = {ages_by_nickname = {alice = 30, bob = 25}} local back = pb.json.decode(hello.Person_descriptor, pb.json.encode(hello.Person_descriptor, p)) t.assert_equals(back.ages_by_nickname.alice, 30) t.assert_equals(back.ages_by_nickname.bob, 25) end -- --------------------------------------------------------------------------- -- WKT -- --------------------------------------------------------------------------- local gwkt = t.group('json.wkt') gwkt.test_timestamp_iso_8601 = function() local datetime = require('datetime') local dt = datetime.new({timestamp = 1700000000, nsec = 123456789}) local enc = pb.json.encode(hello.Event_descriptor, {created_at = dt}) local obj = reparse(enc) -- Tarantool's datetime tostring is ISO 8601: "2023-11-14T22:13:20.123456789Z" t.assert_str_matches(obj.createdAt, '^%d%d%d%d%-%d%d%-%d%dT.+Z$') local back = pb.json.decode(hello.Event_descriptor, enc) t.assert(datetime.is_datetime(back.created_at)) t.assert_equals(back.created_at.epoch, 1700000000) t.assert_equals(back.created_at.nsec, 123456789) end gwkt.test_duration_string = function() local enc = pb.json.encode(hello.Event_descriptor, {duration = {seconds = 5, nanos = 0}}) local obj = reparse(enc) t.assert_equals(obj.duration, '5s') local enc2 = pb.json.encode(hello.Event_descriptor, {duration = {seconds = 5, nanos = 1}}) t.assert_equals(reparse(enc2).duration, '5.000000001s') local back = pb.json.decode(hello.Event_descriptor, enc2) t.assert_equals(tonumber(back.duration.seconds), 5) t.assert_equals(back.duration.nanos, 1) end gwkt.test_empty = function() local enc = pb.json.encode(hello.Event_descriptor, {ack = {}}) local obj = reparse(enc) t.assert_equals(type(obj.ack), 'table') end gwkt.test_wrappers_unwrap = function() local enc = pb.json.encode(hello.Event_descriptor, { retry_count = 5, note = 'remember', is_admin = true, }) local obj = reparse(enc) t.assert_equals(obj.retryCount, 5, 'Int32Value unwrapped on encode') t.assert_equals(obj.note, 'remember', 'StringValue unwrapped') t.assert_equals(obj.isAdmin, true, 'BoolValue unwrapped') -- Zero-value wrappers preserve presence: round-trip through JSON. local enc0 = pb.json.encode(hello.Event_descriptor, {retry_count = 0}) t.assert_equals(reparse(enc0).retryCount, 0) local back = pb.json.decode(hello.Event_descriptor, enc) t.assert_equals(back.retry_count, 5) t.assert_equals(back.note, 'remember') t.assert_equals(back.is_admin, true) end -- --------------------------------------------------------------------------- -- Struct / Value / ListValue -- --------------------------------------------------------------------------- local gsv = t.group('json.struct_value') gsv.test_struct_field_emits_object = function() local enc = pb.json.encode(hello.Event_descriptor, { payload = pb.wkt.struct({k = 'v', n = 42, on = true}), }) local obj = reparse(enc) t.assert_equals(type(obj.payload), 'table') t.assert_equals(obj.payload.k, 'v') t.assert_equals(obj.payload.n, 42) t.assert_equals(obj.payload.on, true) end gsv.test_value_field_dispatches_on_lua_type = function() local cases = { {input = 'hello', expect = 'hello'}, {input = 42, expect = 42}, {input = true, expect = true}, {input = pb.NULL, expect = box.NULL}, } for _, c in ipairs(cases) do local enc = pb.json.encode(hello.Event_descriptor, {attribute = c.input}) local obj = reparse(enc) t.assert_equals(obj.attribute, c.expect) end end gsv.test_list_value_field_emits_array = function() local enc = pb.json.encode(hello.Event_descriptor, { tags = pb.wkt.list({'alpha', 7, false, pb.NULL}), }) local obj = reparse(enc) t.assert_equals(#obj.tags, 4) t.assert_equals(obj.tags[1], 'alpha') t.assert_equals(obj.tags[2], 7) t.assert_equals(obj.tags[3], false) t.assert_equals(obj.tags[4], box.NULL) end gsv.test_struct_value_json_round_trip = function() local e = { payload = pb.wkt.struct({nested = pb.wkt.struct({k = 1})}), attribute = pb.wkt.list({'a', 'b'}), tags = pb.wkt.list({pb.NULL, true, 'x'}), } local enc = pb.json.encode(hello.Event_descriptor, e) local back = pb.json.decode(hello.Event_descriptor, enc) t.assert_equals(back.payload.nested.k, 1) t.assert_equals(back.attribute[1], 'a') t.assert_equals(back.attribute[2], 'b') t.assert_equals(back.tags[1], pb.NULL) t.assert_equals(back.tags[2], true) t.assert_equals(back.tags[3], 'x') end gsv.test_decode_json_null_is_pb_null_in_value = function() -- Top-level Value: decode a literal JSON null. local v = pb.json.decode(pb.wkt.Value_descriptor, 'null') t.assert_equals(v, pb.NULL) end -- --------------------------------------------------------------------------- -- Oneof -- --------------------------------------------------------------------------- local gone = t.group('json.oneof') gone.test_oneof_branch_emitted = function() local enc = pb.json.encode(hello.Result_descriptor, {id = 7, text = 'hi'}) local obj = reparse(enc) t.assert_equals(obj.id, 7) t.assert_equals(obj.text, 'hi') t.assert_equals(obj.code, nil) t.assert_equals(obj.details, nil) end gone.test_oneof_default_value_branch = function() -- text='' is the proto3 default for string but presence is meaningful -- inside an oneof: it must survive JSON round-trip. local enc = pb.json.encode(hello.Result_descriptor, {text = ''}) t.assert_str_contains(enc, '"text"') local back = pb.json.decode(hello.Result_descriptor, enc) t.assert_equals(back.text, '') end -- --------------------------------------------------------------------------- -- Strict validation (regression tests pinning the proto3 conformance pass) -- --------------------------------------------------------------------------- local gstrict = t.group('json.strict') local proto3 = require('full.protobuf_test_messages.proto3.test_messages_proto3_pb') local P3 = proto3.TestAllTypesProto3_descriptor gstrict.test_duplicate_literal_keys_rejected = function() -- Tarantool's json.decode silently keeps the last value of a duplicate -- key; the find_duplicate_json_keys pre-scan in M.decode catches them. -- Pins Recommended.Proto3.JsonInput.FieldNameDuplicate. local ok, err = pcall(pb.json.decode, P3, '{"optionalInt32": 1, "optionalInt32": 2}') t.assert_not(ok) t.assert_str_contains(err, 'duplicate JSON key') end gstrict.test_duplicate_camel_snake_aliases_rejected = function() -- Both `optional_nested_message` (snake) and `optionalNestedMessage` -- (camel) refer to the same proto field. Mainline rejects, even -- though Lua's hash sees them as distinct keys. local ok, err = pcall(pb.json.decode, P3, [[{ "optional_nested_message": {"a": 1}, "optionalNestedMessage": {"a": 2} }]]) t.assert_not(ok) t.assert_str_contains(err, 'duplicate field') end gstrict.test_duplicate_keys_in_nested_object_rejected = function() -- The pre-scan tracks per-object frames; duplicate inside a nested -- object must trigger even if the outer keys are unique. local ok, err = pcall(pb.json.decode, P3, '{"optionalNestedMessage": {"a": 1, "a": 2}}') t.assert_not(ok) t.assert_str_contains(err, 'duplicate JSON key') end gstrict.test_repeated_primitive_element_null_rejected = function() local ok, err = pcall(pb.json.decode, P3, '{"repeatedInt32": [1, null, 2]}') t.assert_not(ok) t.assert_str_contains(err, 'JSON null') end gstrict.test_repeated_message_element_null_rejected = function() local ok, err = pcall(pb.json.decode, P3, '{"repeatedNestedMessage": [{"a":1}, null]}') t.assert_not(ok) t.assert_str_contains(err, 'JSON null') end gstrict.test_map_value_null_rejected = function() local ok, err = pcall(pb.json.decode, P3, '{"mapInt32Int32": {"0": null}}') t.assert_not(ok) t.assert_str_contains(err, 'JSON null') end gstrict.test_unknown_enum_name_rejected_singular = function() local ok, err = pcall(pb.json.decode, P3, '{"optionalNestedEnum": "DEFINITELY_NOT_A_VALUE"}') t.assert_not(ok) t.assert_str_contains(err, 'unknown enum value') end gstrict.test_unknown_enum_name_rejected_in_repeated = function() local ok, err = pcall(pb.json.decode, P3, '{"repeatedNestedEnum": ["FOO", "NOPE"]}') t.assert_not(ok) t.assert_str_contains(err, 'unknown enum value') end gstrict.test_unknown_enum_name_rejected_in_map_value = function() local ok, err = pcall(pb.json.decode, P3, '{"mapStringNestedEnum": {"k": "NOPE"}}') t.assert_not(ok) t.assert_str_contains(err, 'unknown enum value') end gstrict.test_unknown_enum_name_silently_dropped_with_ignore = function() -- Under the `ignore_unknown_fields` opt (conformance category -- JSON_IGNORE_UNKNOWN_PARSING_TEST), unknown enum names are dropped -- from the result rather than raising — and other valid fields -- come through intact. local m = pb.json.decode(P3, '{"repeatedNestedEnum": ["FOO", "NOPE", "BAR"]}', {ignore_unknown_fields = true}) t.assert_equals(m.repeated_nested_enum, {0, 1}) end gstrict.test_unknown_enum_integer_passes_through = function() -- Unknown enum *integers* are forward-compat per proto3 — never -- rejected, never dropped, no flag needed. local m = pb.json.decode(P3, '{"optionalNestedEnum": 999}') t.assert_equals(m.optional_nested_enum, 999) end gstrict.test_null_value_oneof_set_by_json_null = function() -- NullValue-typed oneof member: input JSON `null` MUST mark the -- oneof as active (set to NULL_VALUE = 0). Mainline pins this via -- NullValueInOtherOneofNewFormat.Validator. local m = pb.json.decode(P3, '{"oneofNullValue": null}') t.assert_equals(m.oneof_null_value, 0) end gstrict.test_null_value_oneof_emits_json_null = function() -- Encode side: NullValue's JSON form is the literal null, not the -- enum string "NULL_VALUE". Pins NullValueInOtherOneofOldFormat. local enc = pb.json.encode(P3, {oneof_null_value = 0}) t.assert_str_contains(enc, '"oneofNullValue":null') end gstrict.test_fieldmask_strict_paths_round_trip = function() -- snake_case input that round-trips cleanly through camelCase -- (lowercase letters + underscores before lowercase letters only). local enc = pb.json.encode(P3, {optional_field_mask = {'foo_bar', 'baz'}}) t.assert_str_contains(enc, '"optionalFieldMask":"fooBar,baz"') end gstrict.test_fieldmask_rejects_uppercase_in_path = function() -- Path that's not already snake_case is malformed; would lose info -- on the round-trip. Pins FieldMaskPathsDontRoundTrip. local ok, err = pcall(pb.json.encode, P3, {optional_field_mask = {'fooBar'}}) t.assert_not(ok) t.assert_str_contains(err, 'snake_case') end gstrict.test_fieldmask_rejects_double_underscore = function() -- "foo__bar" → "fooBar" → "foo_bar" — loses one underscore. -- Pins FieldMaskTooManyUnderscore. local ok, err = pcall(pb.json.encode, P3, {optional_field_mask = {'foo__bar'}}) t.assert_not(ok) t.assert_str_contains(err, 'consecutive underscores') end gstrict.test_fieldmask_rejects_underscore_before_digit = function() -- "foo_3_bar" → "foo3Bar" → "foo3_bar" — irreversible. -- Pins FieldMaskNumbersDontRoundTrip. local ok, err = pcall(pb.json.encode, P3, {optional_field_mask = {'foo_3_bar'}}) t.assert_not(ok) t.assert_str_contains(err, 'non-letter') end gstrict.test_fieldmask_rejects_underscore_in_json_input = function() -- JSON form must be lowerCamelCase; underscores are illegal in -- input. Pins FieldMaskInvalidCharacter. local ok, err = pcall(pb.json.decode, P3, '{"optionalFieldMask": "foo,bar_bar"}') t.assert_not(ok) t.assert_str_contains(err, 'underscore') end -- --------------------------------------------------------------------------- -- M.encode(desc, t, opts): canonical proto3 JSON options -- * use_proto_names -> snake_case field names -- * emit_defaults -> zero scalars + empty containers preserved -- * indent -> pretty-printed output -- -- Parameterized over both codegen modes. JSON encoding is driven by the -- descriptor table, which is mode-independent by contract — running each -- assertion against both `full` and `runtime` descriptors pins that -- "one shape, four producers" guarantee for this surface. -- --------------------------------------------------------------------------- local MODES = { full = require('full.hello.hello_pb'), runtime = require('runtime.hello.hello_pb'), } for mode, mod in pairs(MODES) do local gopts = t.group('json.encode_opts.' .. mode) local PERSON = mod.Person_descriptor gopts.test_use_proto_names_emits_snake_case = function() local enc = pb.json.encode(PERSON, {user_id = ffi.cast('uint64_t', 7), lucky_numbers = {1, 2}}, {use_proto_names = true}) t.assert_str_contains(enc, '"user_id"') t.assert_str_contains(enc, '"lucky_numbers"') t.assert_not_str_contains(enc, '"userId"') t.assert_not_str_contains(enc, '"luckyNumbers"') end gopts.test_use_proto_names_round_trips_via_decoder = function() -- The decoder accepts both spellings; this verifies that the -- snake_case output we just produced decodes back to the same shape. local original = {name = 'Bob', user_id = ffi.cast('uint64_t', 42)} local enc = pb.json.encode(PERSON, original, {use_proto_names = true}) local p = pb.json.decode(PERSON, enc) t.assert_equals(p.name, 'Bob') t.assert_equals(p.user_id, ffi.cast('uint64_t', 42)) end gopts.test_emit_defaults_keeps_zero_scalar_present_in_input = function() -- name = '' is the canonical regression: today this is silently -- dropped, breaking parity with grpc-gateway / etcd Status responses -- that explicitly carry a zero-valued field on the wire. local enc = pb.json.encode(PERSON, {name = '', age = 0}, {emit_defaults = true}) local obj = reparse(enc) t.assert_equals(obj.name, '') t.assert_equals(obj.age, 0) end gopts.test_emit_defaults_synthesizes_absent_implicit_fields = function() -- Empty input table — every implicit-presence scalar gets a zero, -- every repeated becomes [], every map becomes {}. Optional/oneof -- fields stay absent (presence semantics). local enc = pb.json.encode(PERSON, {}, {emit_defaults = true}) local obj = reparse(enc) t.assert_equals(obj.name, '') t.assert_equals(obj.age, 0) t.assert_equals(obj.emails, {}) t.assert_equals(obj.luckyNumbers, {}) -- Map fields emit as objects, not arrays. t.assert_equals(type(obj.agesByNickname), 'table') t.assert_equals(next(obj.agesByNickname), nil) end gopts.test_emit_defaults_alias_always_emit_zero_value = function() -- protojson v2 renamed the flag; accept both spellings. local enc = pb.json.encode(PERSON, {name = ''}, {always_emit_zero_value = true}) t.assert_str_contains(enc, '"name":""') end gopts.test_emit_defaults_skips_message_field = function() -- Singular message fields always have presence semantics in proto3; -- emit_defaults must not synthesize an empty object for them. local enc = pb.json.encode(PERSON, {}, {emit_defaults = true}) t.assert_not_str_contains(enc, '"address"') end gopts.test_indent_pretty_prints = function() local enc = pb.json.encode(PERSON, {name = 'Alice', emails = {'a@x', 'b@x'}}, {indent = ' '}) t.assert_str_contains(enc, '\n "') -- Object keys land on their own indented lines. t.assert_str_contains(enc, ' "name": "Alice"') -- Round-trips through the JSON parser. t.assert_equals(reparse(enc).name, 'Alice') t.assert_equals(reparse(enc).emails, {'a@x', 'b@x'}) end gopts.test_indent_empty_containers_stay_compact = function() local enc = pb.json.encode(PERSON, {emails = {}}, {emit_defaults = true, indent = ' '}) t.assert_str_contains(enc, '"emails": []') end gopts.test_unknown_opts_key_is_ignored = function() -- Forward-compat: extra option keys must not error. local enc = pb.json.encode(PERSON, {name = 'Alice'}, {some_future_option = true}) t.assert_equals(reparse(enc).name, 'Alice') end gopts.test_opts_rejects_non_table = function() local ok, err = pcall(pb.json.encode, PERSON, {}, 'oops') t.assert_not(ok) t.assert_str_contains(err, 'opts must be a table') end end -- Direct cross-mode parity: identical input + opts must produce -- byte-identical JSON regardless of which generator emitted the -- descriptor. Catches a future divergence in the descriptor contract -- that today's per-mode groups would mask (each runs in isolation). local gparity = t.group('json.encode_opts.parity_full_vs_runtime') local function assert_parity(input, opts) local a = pb.json.encode(MODES.full.Person_descriptor, input, opts) local b = pb.json.encode(MODES.runtime.Person_descriptor, input, opts) t.assert_equals(a, b, 'full vs runtime JSON diverged for opts=' .. require('json').encode(opts or {})) end gparity.test_use_proto_names = function() assert_parity({user_id = ffi.cast('uint64_t', 7), lucky_numbers = {1, 2}}, {use_proto_names = true}) end gparity.test_emit_defaults_on_absent = function() assert_parity({}, {emit_defaults = true}) end gparity.test_emit_defaults_on_present_zero = function() assert_parity({name = '', age = 0}, {emit_defaults = true}) end gparity.test_indent = function() -- Indented output has stable formatting; the only nondeterminism in -- the encoder is `pairs` iteration order over the output object, -- which is keyed off Lua's hash. Both modes share the same hash, so -- the byte output stays equal. assert_parity({name = 'Alice', emails = {'a@x'}}, {indent = ' '}) end