Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apisix/plugins/grpc-transcode/proto.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ local pb = require("pb")
local protoc = require("protoc")
local pcall = pcall
local ipairs = ipairs
local pairs = pairs
local decode_base64 = ngx.decode_base64


Expand All @@ -29,6 +30,94 @@ local lrucache_proto = core.lrucache.new({
})

local proto_fake_file = "filename for loaded"
local FIELD_TYPE_MESSAGE = 11

local function ensure_package_prefix(package)
if package and package ~= "" then
return "." .. package .. "."
end

return "."
end

local function create_field_descriptor(field)
return {
name = field.name,
label = field.label,
type = field.type,
type_name = field.type_name,
}
end

local function register_message_descriptor(index, message, prefix)
local full_name = prefix .. message.name
local descriptor = {
full_name = full_name,
fields = {},
map_entry = message.options and message.options.map_entry or false,
}

for _, field in ipairs(message.field or {}) do
descriptor.fields[field.name] = create_field_descriptor(field)
end

index[full_name] = descriptor

local nested_prefix = full_name .. "."
for _, nested in ipairs(message.nested_type or {}) do
register_message_descriptor(index, nested, nested_prefix)
end
end

local function build_message_index_from_file(index, file)
if not file then
return
end

local prefix = ensure_package_prefix(file.package)
for _, message in ipairs(file.message_type or {}) do
register_message_descriptor(index, message, prefix)
end
end

local function mark_map_fields(index)
for _, descriptor in pairs(index) do
if descriptor.map_entry and descriptor.fields then
descriptor.map_value_field = descriptor.fields.value
end
end

for _, descriptor in pairs(index) do
for _, field in pairs(descriptor.fields) do
if field.type == FIELD_TYPE_MESSAGE and field.type_name then
local target = index[field.type_name]
if target and target.map_entry then
field.is_map = true
field.map_entry_descriptor = target
end
end
end
end
end

local function build_message_index(files)
local index = {}
if not files then
return index
end

if files.message_type or files.package then
build_message_index_from_file(index, files)
else
for _, file in ipairs(files) do
build_message_index_from_file(index, file)
end
end

mark_map_fields(index)

return index
end

local function compile_proto_text(content)
protoc.reload()
Expand Down Expand Up @@ -60,6 +149,7 @@ local function compile_proto_text(content)
end

compiled[proto_fake_file].index = index
compiled.message_index = build_message_index(compiled[proto_fake_file])

Comment on lines 151 to 153

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions adding a fetch_proto_array_names function, but the implementation actually builds proto.message_index and relies on set_default_array + descriptor metadata to enforce JSON array encoding. Please update the PR description (or rename references) to reflect the current approach so reviewers/users aren't looking for a function that doesn't exist.

Copilot uses AI. Check for mistakes.
return compiled
end
Expand Down Expand Up @@ -93,6 +183,7 @@ local function compile_proto_bin(content)
local compiled = {}
compiled[proto_fake_file] = {}
compiled[proto_fake_file].index = index
compiled.message_index = build_message_index(files)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work for protos uploaded as binary descriptor sets. pb.decode("google.protobuf.FileDescriptorSet", content) decodes the label and type enums as name strings (lua-protobuf defaults to enum_as_name), so field.label here is "LABEL_REPEATED" and field.type is "TYPE_MESSAGE". The numeric comparisons in response.lua (label == 3, type == 11) then never match, so no array tagging happens at all on this path — I verified this against lua-protobuf 0.5.3 (the version pinned in the rockspec). It's also nondeterministic: pb options are global to the worker, so if another route's pb_option switched to enum_as_value before this proto gets compiled, you'd get numbers instead.

The text path is unaffected because protoc.lua builds descriptor tables with numeric label/type, which is why the new tests pass. If you keep this approach, the label/type values need to be normalized here, and it would be good to add a test case using a binary proto like the existing ones in t/plugin/grpc-transcode2.t (t/grpc_server_example/proto.pb).

return compiled
end

Expand Down
73 changes: 72 additions & 1 deletion apisix/plugins/grpc-transcode/response.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,58 @@ local string = string
local ngx_decode_base64 = ngx.decode_base64
local ipairs = ipairs
local pcall = pcall
local type = type
local pairs = pairs
local setmetatable = setmetatable

local _M = {}

-- Protobuf repeated field label value
local PROTOBUF_REPEATED_LABEL = 3
local FIELD_TYPE_MESSAGE = 11

local function set_default_array(tab, descriptor, message_index)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a much simpler way to achieve this that lets lua-protobuf do all the heavy lifting. The version we pin (0.5.3) supports a special *array default metatable (docs): if we call pb.defaults("*array", core.json.array_mt) once in compile_proto() (inside the pb.state window, right before compiled.pb_state = pb.state(old_pb_state)), then pb.decode attaches the array metatable to every repeated field table by itself.

I verified this locally against lua-protobuf 0.5.3 with the plugin's default options: empty repeated fields encode as [], map fields correctly stay {} (lua-protobuf distinguishes maps from arrays natively), nested repeated fields inside map values and repeated messages also get [], and it works for both the text proto path and the binary descriptor path. So it would replace build_message_index + set_default_array entirely with one line, with zero per-request traversal cost.

One caveat: pb.defaults is tied to the active pb state, so it must be set per compiled proto rather than at module load time. What do you think?

if type(tab) ~= "table" or not descriptor or not descriptor.fields then
return
end

for field_name, field_info in pairs(descriptor.fields) do
local value = tab[field_name]
if value ~= nil and type(value) == "table" then
if field_info.label == PROTOBUF_REPEATED_LABEL and not field_info.is_map then
setmetatable(value, core.json.array_mt)
end

if field_info.type == FIELD_TYPE_MESSAGE then
if field_info.is_map then
local map_entry = field_info.map_entry_descriptor
local map_value_field = map_entry and map_entry.map_value_field
if map_value_field and map_value_field.type == FIELD_TYPE_MESSAGE then
local nested_desc = message_index and
message_index[map_value_field.type_name]
if nested_desc then
for _, map_val in pairs(value) do
set_default_array(map_val, nested_desc, message_index)
end
end
end
else
local nested_desc = message_index and
message_index[field_info.type_name]
if nested_desc then
if field_info.label == PROTOBUF_REPEATED_LABEL then
for _, item in ipairs(value) do
set_default_array(item, nested_desc, message_index)
end
else
set_default_array(value, nested_desc, message_index)
end
end
end
end
end
end
end


local function handle_error_response(status_detail_type, proto)
Expand Down Expand Up @@ -93,7 +145,8 @@ local function handle_error_response(status_detail_type, proto)
end


return function(ctx, proto, service, method, pb_option, show_status_in_body, status_detail_type)
local function transform_response(ctx, proto, service, method, pb_option,
show_status_in_body, status_detail_type)
local buffer = core.response.hold_body_chunk(ctx)
if not buffer then
return nil
Expand Down Expand Up @@ -132,6 +185,14 @@ return function(ctx, proto, service, method, pb_option, show_status_in_body, sta
return err_msg
end

local message_index = proto and proto.message_index
if message_index then
local output_descriptor = message_index[m.output_type]
if output_descriptor then
set_default_array(decoded, output_descriptor, message_index)
end
end

local response, err = core.json.encode(decoded)
if not response then
err_msg = "failed to json_encode response body"
Expand All @@ -142,3 +203,13 @@ return function(ctx, proto, service, method, pb_option, show_status_in_body, sta
ngx.arg[1] = response
return nil
end

_M._TEST = {
set_default_array = set_default_array,
}

return setmetatable(_M, {
__call = function(_, ...)
return transform_response(...)
end
})
158 changes: 158 additions & 0 deletions t/plugin/grpc-transcode-arrays.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
use t::APISIX 'no_plan';

no_long_string();
no_shuffle();
no_root_location();

add_block_preprocessor(sub {
my ($block) = @_;

if (!$block->request) {
$block->set_value("request", "GET /t");
}
});

run_tests;

__DATA__

=== TEST 1: repeated field name scoped to its message
--- config
location /t {
content_by_lua_block {
local response = require("apisix.plugins.grpc-transcode.response")
local helpers = response._TEST

local message_index = {
[".demo.Company"] = {
fields = {
name = {label = 3, type = 9},
},
},
[".demo.InnerName"] = {
fields = {
part = {label = 1, type = 9},
},
},
[".demo.HelloRequest"] = {
fields = {
company = {label = 1, type = 11, type_name = ".demo.Company"},
name = {label = 1, type = 11, type_name = ".demo.InnerName"},
},
}
}

local data = {
company = { name = {"foo", "bar"} },
name = { part = "ceo" },
}

helpers.set_default_array(data,
message_index[".demo.HelloRequest"], message_index)

local array_mt = require("apisix.core").json.array_mt

if getmetatable(data.company.name) ~= array_mt then
ngx.status = 500
ngx.say("company.name isn't treated as an array")
return
end

if getmetatable(data.name) == array_mt then
ngx.status = 500
ngx.say("nested message incorrectly converted to array")
return
end

ngx.say("passed")
}
}
--- response_body
passed



=== TEST 2: map values keep object semantics while nested arrays are applied
--- config
location /t {
content_by_lua_block {
local response = require("apisix.plugins.grpc-transcode.response")
local helpers = response._TEST

local member_descriptor = {
fields = {
alias = {label = 3, type = 9},
}
}

local map_entry_descriptor = {
map_entry = true,
fields = {
key = {label = 1, type = 9},
value = {label = 1, type = 11, type_name = ".demo.Member"},
},
}
map_entry_descriptor.map_value_field = map_entry_descriptor.fields.value

local team_descriptor = {
fields = {
members = {
label = 3,
type = 11,
type_name = ".demo.Team.MemberEntry",
is_map = true,
map_entry_descriptor = map_entry_descriptor,
}
}
}

local message_index = {
[".demo.Member"] = member_descriptor,
[".demo.Team.MemberEntry"] = map_entry_descriptor,
[".demo.Team"] = team_descriptor,
}

local data = {
members = {
alice = { alias = {"aa", "ab"} },
bob = { alias = {"ba"} },
}
}

helpers.set_default_array(data, message_index[".demo.Team"], message_index)

local array_mt = require("apisix.core").json.array_mt

if getmetatable(data.members) == array_mt then
ngx.status = 500
ngx.say("map field should not become an array")
return
end

if getmetatable(data.members.alice.alias) ~= array_mt then
ngx.status = 500
ngx.say("map values should still apply nested arrays")
return
end

ngx.say("passed")
}
}
--- response_body
passed
Loading
Loading