Skip to content

Commit c7a76b2

Browse files
authored
fix(grpc-transcode): encode empty repeated fields as JSON arrays (#13678)
An empty `repeated` field is decoded by lua-protobuf into an empty Lua table, which cjson then encodes as `{}` instead of `[]`. Set the `*array` default metatable to `core.json.array_mt` on the pb state of each compiled proto, so lua-protobuf tags every repeated field as an array on decode. Maps keep their object semantics, and both the text and the binary descriptor paths are covered. Fixes #11440
1 parent 8f32786 commit c7a76b2

3 files changed

Lines changed: 230 additions & 0 deletions

File tree

apisix/plugins/grpc-transcode/proto.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ local function compile_proto(content)
109109
end
110110
end
111111

112+
-- an empty repeated field is decoded into an empty Lua table, which cjson would
113+
-- encode as `{}`. Tag it as an array so that it is encoded as `[]` instead.
114+
-- The default is bound to the current pb state, so it has to be set per proto.
115+
pb.defaults("*array", core.json.array_mt)
116+
112117
-- fetch pb state
113118
compiled.pb_state = pb.state(old_pb_state)
114119
return compiled
@@ -238,6 +243,8 @@ local function init_status_pb_state()
238243
return "failed to load grpc status protocol: " .. err
239244
end
240245

246+
pb.defaults("*array", core.json.array_mt)
247+
241248
status_pb_state = pb.state(old_pb_state)
242249
end
243250
end

t/plugin/grpc-transcode.t

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,3 +761,79 @@ POST /grpctest
761761
Content-Type: application/json
762762
--- response_body eval
763763
qr/"gender":2/
764+
765+
766+
767+
=== TEST 30: set proto(id: 1) and route with a repeated field in the reply
768+
--- config
769+
location /t {
770+
content_by_lua_block {
771+
local t = require("lib.test_admin").test
772+
local code, body = t('/apisix/admin/protos/1',
773+
ngx.HTTP_PUT,
774+
[[{
775+
"content" : "syntax = \"proto3\";
776+
package helloworld;
777+
service Greeter {
778+
rpc SayHello (HelloRequest) returns (HelloReply) {}
779+
}
780+
message HelloRequest {
781+
string name = 1;
782+
repeated string items = 2;
783+
}
784+
message HelloReply {
785+
string message = 1;
786+
repeated string items = 2;
787+
}"
788+
}]]
789+
)
790+
791+
if code >= 300 then
792+
ngx.status = code
793+
ngx.say(body)
794+
return
795+
end
796+
797+
local code, body = t('/apisix/admin/routes/1',
798+
ngx.HTTP_PUT,
799+
[[{
800+
"methods": ["POST"],
801+
"uri": "/grpctest",
802+
"plugins": {
803+
"grpc-transcode": {
804+
"proto_id": "1",
805+
"service": "helloworld.Greeter",
806+
"method": "SayHello"
807+
}
808+
},
809+
"upstream": {
810+
"scheme": "grpc",
811+
"type": "roundrobin",
812+
"nodes": {
813+
"127.0.0.1:10051": 1
814+
}
815+
}
816+
}]]
817+
)
818+
819+
if code >= 300 then
820+
ngx.status = code
821+
end
822+
ngx.say(body)
823+
}
824+
}
825+
--- request
826+
GET /t
827+
--- response_body
828+
passed
829+
830+
831+
832+
=== TEST 31: hit route, an empty repeated field is encoded as an empty array
833+
--- request
834+
POST /grpctest
835+
{"name":"world"}
836+
--- more_headers
837+
Content-Type: application/json
838+
--- response_body eval
839+
qr/"items":\[\]/

t/plugin/grpc-transcode4.t

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
use t::APISIX 'no_plan';
18+
19+
repeat_each(1);
20+
no_long_string();
21+
no_shuffle();
22+
no_root_location();
23+
24+
add_block_preprocessor(sub {
25+
my ($block) = @_;
26+
27+
if (!$block->request) {
28+
$block->set_value("request", "GET /t");
29+
}
30+
});
31+
32+
run_tests;
33+
34+
__DATA__
35+
36+
=== TEST 1: empty repeated fields are decoded as arrays, maps stay objects
37+
--- config
38+
location /t {
39+
content_by_lua_block {
40+
local grpc_proto = require("apisix.plugins.grpc-transcode.proto")
41+
local core = require("apisix.core")
42+
local pb = require("pb")
43+
44+
local compiled = assert(grpc_proto.compile_proto([[
45+
syntax = "proto3";
46+
package t;
47+
message Inner {
48+
repeated string tags = 1;
49+
}
50+
message Reply {
51+
string msg = 1;
52+
repeated string items = 2;
53+
map<string, Inner> members = 3;
54+
Inner inner = 4;
55+
repeated Inner list = 5;
56+
}
57+
]]))
58+
59+
local old_state = pb.state(compiled.pb_state)
60+
local bin = pb.encode("t.Reply", {
61+
msg = "hi",
62+
members = {alice = {}},
63+
inner = {},
64+
list = {{}},
65+
})
66+
local decoded = pb.decode("t.Reply", bin)
67+
pb.state(old_state)
68+
69+
ngx.say("items: ", core.json.encode(decoded.items))
70+
ngx.say("members: ", core.json.encode(decoded.members))
71+
ngx.say("nested in map: ", core.json.encode(decoded.members.alice.tags))
72+
ngx.say("nested in message: ", core.json.encode(decoded.inner.tags))
73+
ngx.say("nested in repeated: ", core.json.encode(decoded.list[1].tags))
74+
}
75+
}
76+
--- response_body
77+
items: []
78+
members: {"alice":{"tags":[]}}
79+
nested in map: []
80+
nested in message: []
81+
nested in repeated: []
82+
83+
84+
85+
=== TEST 2: non-empty repeated fields are unaffected
86+
--- config
87+
location /t {
88+
content_by_lua_block {
89+
local grpc_proto = require("apisix.plugins.grpc-transcode.proto")
90+
local core = require("apisix.core")
91+
local pb = require("pb")
92+
93+
local compiled = assert(grpc_proto.compile_proto([[
94+
syntax = "proto3";
95+
package t;
96+
message Reply {
97+
repeated string items = 1;
98+
}
99+
]]))
100+
101+
local old_state = pb.state(compiled.pb_state)
102+
local bin = pb.encode("t.Reply", {items = {"a", "b"}})
103+
local decoded = pb.decode("t.Reply", bin)
104+
pb.state(old_state)
105+
106+
ngx.say(core.json.encode(decoded.items))
107+
}
108+
}
109+
--- response_body
110+
["a","b"]
111+
112+
113+
114+
=== TEST 3: also works for protos loaded from a binary descriptor set
115+
--- config
116+
location /t {
117+
content_by_lua_block {
118+
local grpc_proto = require("apisix.plugins.grpc-transcode.proto")
119+
local core = require("apisix.core")
120+
local protoc = require("protoc")
121+
local pb = require("pb")
122+
123+
-- build a FileDescriptorSet, the same format the Admin API accepts
124+
protoc.reload()
125+
local parsed = protoc.new():parse([[
126+
syntax = "proto3";
127+
package t;
128+
message Reply {
129+
string msg = 1;
130+
repeated string items = 2;
131+
}
132+
]])
133+
local descriptor_set = pb.encode("google.protobuf.FileDescriptorSet",
134+
{file = {parsed}})
135+
136+
local compiled = assert(grpc_proto.compile_proto(
137+
ngx.encode_base64(descriptor_set)))
138+
139+
local old_state = pb.state(compiled.pb_state)
140+
local decoded = pb.decode("t.Reply", pb.encode("t.Reply", {msg = "hi"}))
141+
pb.state(old_state)
142+
143+
ngx.say(core.json.encode(decoded.items))
144+
}
145+
}
146+
--- response_body
147+
[]

0 commit comments

Comments
 (0)