From 7e9e3e43ae121408deeb9a55e6cef220215c37d2 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 17 May 2026 06:36:17 +0300 Subject: [PATCH] json: M.encode(desc, t, opts) for use_proto_names / emit_defaults / indent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical protojson options were silently ignored. Plumb a single opts table through encode_message via a CURRENT_ENCODE_OPTS state (mirroring the decode-side CURRENT_OPTS): * use_proto_names — emit snake_case field names (proto wire names) instead of the spec-default lowerCamelCase. * emit_defaults — emit zero-valued implicit-presence scalars, (alias: always_emit_zero_value) empty repeated lists, and empty maps. Explicit-presence fields (optional / oneof) and singular message fields remain absent — matches protobuf-go's behavior. * indent — pretty-print with the given indent string; empty arrays/objects stay compact. Tests parameterize over both codegen modes (descriptor-table contract is shared between full and runtime) plus a dedicated parity group that asserts byte-identical JSON across modes for each option. --- runtime/pb/json.lua | 131 +++++++++++++++++++++++++++++++++++++-- test/json_test.lua | 145 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 6 deletions(-) diff --git a/runtime/pb/json.lua b/runtime/pb/json.lua index e6a2ea6670f452c0cc5accd907521bb25bafac9d..28e9a6c748b2393a30434e830b763a1345ee05cb 100644 --- a/runtime/pb/json.lua +++ b/runtime/pb/json.lua @@ -552,6 +552,44 @@ encode_json = function(v) error('JSON encode: unsupported type ' .. ty, 0) end +-- Pretty-printer used when `M.encode(desc, t, {indent = " "})` is set. +-- Indent is repeated per nesting level; key/value separator becomes `: `; +-- empty arrays/objects stay on one line as `[]` / `{}`. +local encode_json_pretty +encode_json_pretty = function(v, indent, depth) + if v == nil then return 'null' end + local ty = type(v) + if ty == 'cdata' then + if v == box.NULL then return 'null' end + return (tostring(v):gsub('U?LL$', '')) + end + if ty == 'boolean' then return v and 'true' or 'false' end + if ty == 'number' then return encode_json_number(v) end + if ty == 'string' then return encode_json_string(v) end + if ty == 'table' then + local mt = getmetatable(v) + local outer = string.rep(indent, depth) + local inner = string.rep(indent, depth + 1) + if is_array_table(v, mt) then + if #v == 0 then return '[]' end + local parts = {} + for i = 1, #v do parts[i] = encode_json_pretty(v[i], indent, depth + 1) end + return '[\n' .. inner .. table.concat(parts, ',\n' .. inner) + .. '\n' .. outer .. ']' + end + local parts, n = {}, 0 + for k, vv in pairs(v) do + n = n + 1 + parts[n] = encode_json_string(tostring(k)) .. ': ' + .. encode_json_pretty(vv, indent, depth + 1) + end + if n == 0 then return '{}' end + return '{\n' .. inner .. table.concat(parts, ',\n' .. inner) + .. '\n' .. outer .. '}' + end + error('JSON encode: unsupported type ' .. ty, 0) +end + -- --------------------------------------------------------------------------- -- Encode (proto-Lua table -> Lua structure suitable for our JSON emitter) -- --------------------------------------------------------------------------- @@ -789,6 +827,41 @@ local function encode_wkt(desc, v) return nil end +-- Set by M.encode for the duration of a call. See CURRENT_OPTS for the +-- decode-side analogue. Read by encode_message to honor: +-- * use_proto_names — emit snake_case field names (proto wire names) +-- instead of the spec-default lowerCamelCase +-- * emit_defaults — emit zero-valued scalars/enums, empty +-- (alias: always_emit_zero_value) repeated/map fields, even when +-- implicit-presence semantics would skip them. +-- Explicit-presence fields (`optional`/`oneof`) +-- and singular message fields remain absent +-- when unset. +local CURRENT_ENCODE_OPTS = nil + +-- Zero value to emit for an implicit-presence scalar/enum field when +-- emit_defaults is on and the field is absent from the input table. +-- Returns nil for kinds that should never be synthesized (message, +-- repeated, map — handled separately). +local FFI_INT64_ZERO = ffi.cast(INT64_T, 0) +local FFI_UINT64_ZERO = ffi.cast(UINT64_T, 0) +local function zero_value_for_field(f) + if f.kind == 'scalar' then + local pt = f.proto_type + if pt == 'string' or pt == 'bytes' then return '' end + if pt == 'bool' then return false end + if pt == 'int64' or pt == 'sint64' or pt == 'sfixed64' then + return FFI_INT64_ZERO + end + if pt == 'uint64' or pt == 'fixed64' then + return FFI_UINT64_ZERO + end + return 0 + end + if f.kind == 'enum' then return 0 end + return nil +end + encode_message = function(desc, t) if rawequal(t, nil) then return nil end local override = encode_wkt(desc, t) @@ -796,30 +869,38 @@ encode_message = function(desc, t) -- legitimately return null as its override. if not rawequal(override, nil) then return override end + local opts = CURRENT_ENCODE_OPTS + local emit_defaults = opts and opts.emit_defaults + local use_proto_names = opts and opts.use_proto_names + local out = setmetatable({}, {__serialize='map'}) for _, f in ipairs(desc.fields) do local v = t[f.name] + local key = use_proto_names and f.name or to_camel(f.name) -- box.NULL == nil under Tarantool's __eq metamethod; use rawequal -- so a deliberately-stored null sentinel survives the field walk. if not rawequal(v, nil) then - local key = to_camel(f.name) if f.kind == 'map' then if next(v) ~= nil then - local obj = {} + local obj = setmetatable({}, {__serialize='map'}) for k, mv in pairs(v) do obj[encode_map_key(f.key, k)] = encode_field_value(f.value, mv) end out[key] = obj + elseif emit_defaults then + out[key] = setmetatable({}, {__serialize='map'}) end elseif f.repeated then if #v > 0 then local arr = setmetatable({}, {__serialize='seq'}) for i = 1, #v do arr[i] = encode_field_value(f, v[i]) end out[key] = arr + elseif emit_defaults then + out[key] = setmetatable({}, {__serialize='seq'}) end else local emit = true - if not (f.optional or f.oneof) then + if not emit_defaults and not (f.optional or f.oneof) then if f.kind == 'scalar' then local pt = f.proto_type if pt == 'string' or pt == 'bytes' then @@ -827,7 +908,7 @@ encode_message = function(desc, t) elseif pt == 'bool' then if v == false then emit = false end else - if v == 0 or (type(v) == 'cdata' and v == ffi.cast(INT64_T, 0)) then + if v == 0 or (type(v) == 'cdata' and v == FFI_INT64_ZERO) then emit = false end end @@ -837,6 +918,19 @@ encode_message = function(desc, t) end if emit then out[key] = encode_field_value(f, v) end end + elseif emit_defaults then + -- Field is absent from input. Emit a default only for + -- implicit-presence shapes (proto3 non-optional, non-oneof, + -- non-message). Maps and repeated come out as empty + -- containers; scalars/enums as the zero value. + if f.kind == 'map' then + out[key] = setmetatable({}, {__serialize='map'}) + elseif f.repeated then + out[key] = setmetatable({}, {__serialize='seq'}) + elseif not (f.optional or f.oneof) and f.kind ~= 'message' then + local z = zero_value_for_field(f) + if z ~= nil then out[key] = encode_field_value(f, z) end + end end end return out @@ -844,8 +938,33 @@ end to_json_value = encode_message -function M.encode(desc, t) - return encode_json(encode_message(desc, t)) +function M.encode(desc, t, opts) + if opts ~= nil and type(opts) ~= 'table' then + error('pb.json.encode: opts must be a table, got ' .. type(opts), 0) + end + -- Normalize once so the hot path reads a single boolean. + local norm + if opts ~= nil then + norm = { + use_proto_names = opts.use_proto_names and true or false, + emit_defaults = (opts.emit_defaults or opts.always_emit_zero_value) + and true or false, + indent = opts.indent, + } + if norm.indent ~= nil and type(norm.indent) ~= 'string' then + error('pb.json.encode: indent must be a string', 0) + end + if norm.indent == '' then norm.indent = nil end + end + local prev = CURRENT_ENCODE_OPTS + CURRENT_ENCODE_OPTS = norm + local ok, root_or_err = pcall(encode_message, desc, t) + CURRENT_ENCODE_OPTS = prev + if not ok then error(root_or_err, 0) end + if norm and norm.indent then + return encode_json_pretty(root_or_err, norm.indent, 0) + end + return encode_json(root_or_err) end -- --------------------------------------------------------------------------- diff --git a/test/json_test.lua b/test/json_test.lua index 50d3b0755891491bba40dddde731fe005332a16e..1d74febfc46a91465cba841881f4789b2e188423 100644 --- a/test/json_test.lua +++ b/test/json_test.lua @@ -403,3 +403,148 @@ gstrict.test_fieldmask_rejects_underscore_in_json_input = function() 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