Skip to main content

grpc_webnext/
transcode.rs

1//! JSON <-> protobuf transcoding for the `+json` codec.
2//!
3//! grpc-webnext carries opaque message bytes; the envelope (frames, trailers)
4//! is always protobuf, but the *application message* may be JSON. Converting
5//! JSON to the binary protobuf that a gRPC handler expects (and back) needs the
6//! message descriptors, so a `Transcoder` is built from a compiled
7//! `FileDescriptorSet` (e.g. `protoc --descriptor_set_out` /
8//! `prost_build ... file_descriptor_set_path`).
9
10use prost_reflect::prost::Message;
11use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor};
12use serde::Serialize;
13
14use crate::httprule::HttpRouter;
15pub use crate::httprule::{HttpCall, WsBinding};
16
17#[derive(Debug, thiserror::Error)]
18pub enum TranscodeError {
19    #[error("failed to load descriptor set: {0}")]
20    Descriptor(String),
21    #[error("unknown method: {0}")]
22    UnknownMethod(String),
23    #[error("json error: {0}")]
24    Json(#[from] serde_json::Error),
25    #[error("protobuf decode error: {0}")]
26    Decode(String),
27    #[error("http transcoding: {0}")]
28    Http(String),
29}
30
31/// Transcodes application messages between JSON and binary protobuf, keyed by
32/// gRPC method path (`/pkg.Service/Method`), and maps `google.api.http` REST
33/// bindings onto gRPC methods.
34#[derive(Clone)]
35pub struct Transcoder {
36    pool: DescriptorPool,
37    router: HttpRouter,
38}
39
40impl Transcoder {
41    /// Build from an encoded `FileDescriptorSet`.
42    pub fn from_file_descriptor_set(bytes: &[u8]) -> Result<Self, TranscodeError> {
43        let pool = DescriptorPool::decode(bytes)
44            .map_err(|e| TranscodeError::Descriptor(e.to_string()))?;
45        let router = HttpRouter::from_pool(&pool);
46        Ok(Self { pool, router })
47    }
48
49    /// Whether any `google.api.http` REST bindings were found.
50    pub fn has_http_rules(&self) -> bool {
51        !self.router.is_empty()
52    }
53
54    /// Map a REST request `(method, path, query, body)` onto a gRPC call, or
55    /// `Ok(None)` if no HTTP binding matches.
56    pub fn transcode_http_request(
57        &self,
58        method: &str,
59        path: &str,
60        query: Option<&str>,
61        body: &[u8],
62    ) -> Result<Option<HttpCall>, TranscodeError> {
63        self.router.transcode(method, path, query, body)
64    }
65
66    /// Resolve a WebSocket annotation route from its upgrade path, or `None` if the
67    /// path matches no binding.
68    pub fn match_ws(&self, path: &str, query: Option<&str>) -> Option<WsBinding> {
69        self.router.match_ws(path, query)
70    }
71
72    /// Whether a gRPC method has any HTTP annotation (used to keep an annotated
73    /// RPC's main route off the plain-HTTP surface).
74    pub fn is_annotated_method(&self, grpc_method: &str) -> bool {
75        self.router.is_annotated(grpc_method)
76    }
77
78    /// Resolve `(request_type, response_type)` for a method path.
79    fn io_types(&self, path: &str) -> Result<(MessageDescriptor, MessageDescriptor), TranscodeError> {
80        let (service, method) = path
81            .trim_start_matches('/')
82            .split_once('/')
83            .ok_or_else(|| TranscodeError::UnknownMethod(path.to_string()))?;
84        let svc = self
85            .pool
86            .get_service_by_name(service)
87            .ok_or_else(|| TranscodeError::UnknownMethod(path.to_string()))?;
88        let m = svc
89            .methods()
90            .find(|m| m.name() == method)
91            .ok_or_else(|| TranscodeError::UnknownMethod(path.to_string()))?;
92        Ok((m.input(), m.output()))
93    }
94
95    /// Whether `path` (`/pkg.Service/Method`) resolves to a known method — i.e. this
96    /// transcoder can handle it. Callers use it to distinguish "unknown method"
97    /// (UNIMPLEMENTED) from a genuine transcode/validation failure.
98    pub fn has_method(&self, path: &str) -> bool {
99        self.io_types(path).is_ok()
100    }
101
102    /// JSON request message -> binary protobuf. Empty input is the default message.
103    pub fn request_json_to_proto(&self, path: &str, json: &[u8]) -> Result<Vec<u8>, TranscodeError> {
104        let (input, _) = self.io_types(path)?;
105        Ok(self.json_to_proto(input, json)?.encode_to_vec())
106    }
107
108    /// Binary protobuf response message -> JSON.
109    pub fn response_proto_to_json(&self, path: &str, proto: &[u8]) -> Result<Vec<u8>, TranscodeError> {
110        self.response_proto_to_json_body(path, proto, "")
111    }
112
113    /// Binary protobuf response message -> JSON, honoring a binding's
114    /// `response_body`: when it names a (top-level) field, the JSON is **that
115    /// field's value** rather than the whole message. An empty name is the
116    /// ordinary whole-message encoding.
117    pub fn response_proto_to_json_body(
118        &self,
119        path: &str,
120        proto: &[u8],
121        response_body: &str,
122    ) -> Result<Vec<u8>, TranscodeError> {
123        let (_, output) = self.io_types(path)?;
124        if response_body.is_empty() {
125            return self.proto_to_json(output, proto);
126        }
127        let field = crate::httprule::field_by_any_name(&output, response_body)
128            .ok_or_else(|| TranscodeError::Http(format!("unknown response_body field: {response_body}")))?;
129
130        // Encode the whole message with the library's own rules, then lift out the
131        // one member. Doing it this way rather than serializing the field value
132        // directly is deliberate: neither prost-reflect nor protojson can encode a
133        // lone value, and re-deriving protobuf-JSON's scalar rules by hand (64-bit
134        // as a string, bytes as base64, enums by name) in two languages is exactly
135        // where the implementations would drift apart.
136        let whole = self.proto_to_json(output, proto)?;
137        let obj: serde_json::Value = serde_json::from_slice(&whole)?;
138        let value = obj.get(field.json_name()).cloned().unwrap_or_else(|| json_zero(&field));
139        Ok(serde_json::to_vec(&value)?)
140    }
141
142    fn json_to_proto(
143        &self,
144        desc: MessageDescriptor,
145        json: &[u8],
146    ) -> Result<DynamicMessage, TranscodeError> {
147        if json.is_empty() {
148            return Ok(DynamicMessage::new(desc));
149        }
150        let mut de = serde_json::Deserializer::from_slice(json);
151        let msg = DynamicMessage::deserialize(desc, &mut de)?;
152        de.end()?;
153        Ok(msg)
154    }
155
156    fn proto_to_json(&self, desc: MessageDescriptor, proto: &[u8]) -> Result<Vec<u8>, TranscodeError> {
157        let msg =
158            DynamicMessage::decode(desc, proto).map_err(|e| TranscodeError::Decode(e.to_string()))?;
159        let mut buf = Vec::new();
160        let mut ser = serde_json::Serializer::new(&mut buf);
161        msg.serialize(&mut ser)?;
162        Ok(buf)
163    }
164}
165
166/// The JSON a field carries when it is **absent** from the encoded message.
167///
168/// Whole-message encoding skips default values, so lifting a member out can come
169/// up empty — but `response_body` promises a body, and a zero is not "no answer".
170/// This is the one place protobuf-JSON's rules are restated by hand, so it is kept
171/// to exactly the zero case, and mirrored field-for-field by the Go implementation
172/// (`jsonZero` in `go/webnext/transcode.go`).
173fn json_zero(field: &prost_reflect::FieldDescriptor) -> serde_json::Value {
174    use prost_reflect::Kind;
175    use serde_json::Value;
176
177    if field.is_list() {
178        return Value::Array(Vec::new());
179    }
180    if field.is_map() {
181        return Value::Object(serde_json::Map::new());
182    }
183    match field.kind() {
184        // An unset message field is null, not `{}` — it was never there.
185        Kind::Message(_) => Value::Null,
186        Kind::String => Value::String(String::new()),
187        // Empty bytes are the empty base64 string.
188        Kind::Bytes => Value::String(String::new()),
189        Kind::Bool => Value::Bool(false),
190        // 64-bit integers are JSON *strings* in protobuf-JSON, zero included.
191        Kind::Int64 | Kind::Sint64 | Kind::Sfixed64 | Kind::Uint64 | Kind::Fixed64 => {
192            Value::String("0".to_string())
193        }
194        // An enum encodes as its value's name; number 0 is the default by
195        // definition in proto3, but fall back to the number if it has none.
196        Kind::Enum(e) => match e.get_value(0) {
197            Some(v) => Value::String(v.name().to_string()),
198            None => Value::from(0),
199        },
200        _ => Value::from(0),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use prost_reflect::prost_types::{
208        field_descriptor_proto::{Label, Type},
209        DescriptorProto, EnumDescriptorProto, EnumValueDescriptorProto, FieldDescriptorProto,
210        FileDescriptorProto, FileDescriptorSet,
211    };
212
213    /// A synthetic message covering every field shape `json_zero` branches on —
214    /// the Rust twin of the Go fixture in `go/webnext/httprule_test.go`.
215    fn zero_fixture() -> MessageDescriptor {
216        let field = |name: &str, number: i32, ty: Type| FieldDescriptorProto {
217            name: Some(name.to_string()),
218            number: Some(number),
219            label: Some(Label::Optional as i32),
220            r#type: Some(ty as i32),
221            ..Default::default()
222        };
223        let typed = |name: &str, number: i32, ty: Type, type_name: &str| FieldDescriptorProto {
224            type_name: Some(type_name.to_string()),
225            ..field(name, number, ty)
226        };
227        let mut tags = field("tags", 20, Type::String);
228        tags.label = Some(Label::Repeated as i32);
229
230        let file = FileDescriptorProto {
231            name: Some("zero_fixture.proto".to_string()),
232            package: Some("webnext.zero.test".to_string()),
233            syntax: Some("proto3".to_string()),
234            enum_type: vec![EnumDescriptorProto {
235                name: Some("Color".to_string()),
236                value: vec![
237                    EnumValueDescriptorProto {
238                        name: Some("COLOR_UNSPECIFIED".to_string()),
239                        number: Some(0),
240                        ..Default::default()
241                    },
242                    EnumValueDescriptorProto {
243                        name: Some("RED".to_string()),
244                        number: Some(1),
245                        ..Default::default()
246                    },
247                ],
248                ..Default::default()
249            }],
250            message_type: vec![
251                DescriptorProto {
252                    name: Some("Nested".to_string()),
253                    field: vec![field("id", 1, Type::String)],
254                    ..Default::default()
255                },
256                DescriptorProto {
257                    name: Some("Req".to_string()),
258                    field: vec![
259                        field("name", 1, Type::String),
260                        field("count", 2, Type::Uint32),
261                        field("big", 3, Type::Int64),
262                        field("ratio", 4, Type::Double),
263                        field("flag", 5, Type::Bool),
264                        field("blob", 6, Type::Bytes),
265                        typed("color", 7, Type::Enum, ".webnext.zero.test.Color"),
266                        typed("nested", 8, Type::Message, ".webnext.zero.test.Nested"),
267                        tags,
268                    ],
269                    ..Default::default()
270                },
271            ],
272            ..Default::default()
273        };
274        let pool = DescriptorPool::from_file_descriptor_set(FileDescriptorSet { file: vec![file] })
275            .expect("build fixture pool");
276        pool.get_message_by_name("webnext.zero.test.Req").expect("Req")
277    }
278
279    /// The zero-value table is the ONE place protobuf-JSON's rules are restated by
280    /// hand, so it is the likeliest place for the two implementations to drift.
281    /// Every kind is pinned here — with `TestJSONZeroPerKind` in
282    /// `go/webnext/httprule_test.go` asserting the identical table.
283    #[test]
284    fn json_zero_per_kind() {
285        let desc = zero_fixture();
286        let cases = [
287            ("name", "\"\""),                     // string
288            ("count", "0"),                       // uint32
289            ("big", "\"0\""),                     // int64 — a JSON *string*
290            ("ratio", "0"),                       // double
291            ("flag", "false"),                    // bool
292            ("blob", "\"\""),                     // bytes — the empty base64 string
293            ("color", "\"COLOR_UNSPECIFIED\""),   // enum — by name, not number
294            ("nested", "null"),                   // an unset message was never there
295            ("tags", "[]"),                       // repeated
296        ];
297        for (name, want) in cases {
298            let field = desc.get_field_by_name(name).expect(name);
299            let got = serde_json::to_string(&json_zero(&field)).expect("encode");
300            assert_eq!(got, want, "json_zero({name})");
301        }
302    }
303
304    /// Extraction end to end: `response_body` returns the named field's value,
305    /// encoded by the library's own rules, and falls back to the zero when absent.
306    #[test]
307    fn response_body_extraction() {
308        let tc = Transcoder::from_file_descriptor_set(testecho::FILE_DESCRIPTOR_SET).expect("transcoder");
309        // echo.v1.Echo/Unary returns EchoResponse { message: string }.
310        const METHOD: &str = "/echo.v1.Echo/Unary";
311        let proto = tc.request_json_to_proto(METHOD, br#"{"message":"hi"}"#).expect("encode");
312
313        // A populated field comes back as its bare JSON value, not wrapped.
314        let body = tc.response_proto_to_json_body(METHOD, &proto, "message").expect("extract");
315        assert_eq!(String::from_utf8(body).unwrap(), "\"hi\"");
316
317        // The whole message is still the default.
318        let whole = tc.response_proto_to_json(METHOD, &proto).expect("whole");
319        assert_eq!(String::from_utf8(whole).unwrap(), r#"{"message":"hi"}"#);
320
321        // An absent field falls back to its zero rather than vanishing.
322        let empty = tc.request_json_to_proto(METHOD, b"{}").expect("encode empty");
323        let body = tc.response_proto_to_json_body(METHOD, &empty, "message").expect("extract");
324        assert_eq!(String::from_utf8(body).unwrap(), "\"\"");
325
326        // A name that is no field at all is an error, not a silent whole-message answer.
327        assert!(tc.response_proto_to_json_body(METHOD, &proto, "nope").is_err());
328    }
329}