M runtime/pb/json.lua => runtime/pb/json.lua +125 -6
@@ 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
-- ---------------------------------------------------------------------------
M test/json_test.lua => test/json_test.lua +145 -0
@@ 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