connectrpc/codec.rs
1//! Message encoding and decoding for ConnectRPC.
2//!
3//! This module provides codec implementations for serializing and deserializing
4//! protobuf messages in both binary proto and JSON formats.
5
6use buffa::Message;
7use bytes::Bytes;
8#[cfg(feature = "json")]
9use serde::Serialize;
10#[cfg(feature = "json")]
11use serde::de::DeserializeOwned;
12
13use crate::error::ConnectError;
14
15/// Content types supported by ConnectRPC.
16pub mod content_type {
17 /// Binary protobuf content type.
18 pub const PROTO: &str = "application/proto";
19 /// JSON content type.
20 pub const JSON: &str = "application/json";
21 /// Connect streaming proto content type.
22 pub const CONNECT_PROTO: &str = "application/connect+proto";
23 /// Connect streaming JSON content type.
24 pub const CONNECT_JSON: &str = "application/connect+json";
25}
26
27/// Connect protocol header names.
28pub mod header {
29 /// Declares the Connect protocol version (always `"1"`).
30 pub const PROTOCOL_VERSION: &str = "connect-protocol-version";
31 /// Request timeout in milliseconds.
32 pub const TIMEOUT_MS: &str = "connect-timeout-ms";
33 /// Content encoding for Connect streaming requests/responses.
34 pub const CONTENT_ENCODING: &str = "connect-content-encoding";
35 /// Accepted content encodings for Connect streaming requests/responses.
36 pub const ACCEPT_ENCODING: &str = "connect-accept-encoding";
37}
38
39/// Marker bound for message types the JSON codec can **serialize**.
40///
41/// When the `json` feature is enabled this is exactly [`serde::Serialize`]:
42/// if a bound such as `T: Message + JsonSerialize` fails to hold, derive
43/// `serde::Serialize` on `T` (generated code does this unless you pass the
44/// codegen `no_json` option). When the feature is disabled it is an empty
45/// bound satisfied by every type, so proto-only message types generated
46/// without serde derives still qualify and the JSON codec is simply
47/// unavailable at runtime.
48///
49/// Auto-implemented for every qualifying type — do not implement it manually.
50#[cfg(feature = "json")]
51pub trait JsonSerialize: Serialize {}
52#[cfg(feature = "json")]
53impl<T: Serialize> JsonSerialize for T {}
54
55/// Marker bound for message types the JSON codec can **serialize**.
56///
57/// With the `json` feature disabled this is an empty bound, so message types
58/// without serde derives satisfy it. See the `json`-enabled definition for
59/// the full contract.
60///
61/// Auto-implemented for every type — do not implement it manually.
62#[cfg(not(feature = "json"))]
63pub trait JsonSerialize {}
64#[cfg(not(feature = "json"))]
65impl<T> JsonSerialize for T {}
66
67/// Marker bound for message types the JSON codec can **deserialize**.
68///
69/// When the `json` feature is enabled this is exactly
70/// [`serde::de::DeserializeOwned`]: if a bound such as
71/// `T: Message + JsonDeserialize` fails to hold, derive `serde::Deserialize`
72/// on `T` (generated code does this unless you pass the codegen `no_json`
73/// option). When the feature is disabled it is an empty bound satisfied by
74/// every type, so proto-only message types generated without serde derives
75/// still qualify.
76///
77/// Auto-implemented for every qualifying type — do not implement it manually.
78#[cfg(feature = "json")]
79pub trait JsonDeserialize: DeserializeOwned {}
80#[cfg(feature = "json")]
81impl<T: DeserializeOwned> JsonDeserialize for T {}
82
83/// Marker bound for message types the JSON codec can **deserialize**.
84///
85/// With the `json` feature disabled this is an empty bound. See the
86/// `json`-enabled definition for the full contract.
87///
88/// Auto-implemented for every type — do not implement it manually.
89#[cfg(not(feature = "json"))]
90pub trait JsonDeserialize {}
91#[cfg(not(feature = "json"))]
92impl<T> JsonDeserialize for T {}
93
94/// Encode a protobuf message to binary format.
95pub fn encode_proto<M: Message>(message: &M) -> Result<Bytes, ConnectError> {
96 Ok(message.encode_to_bytes())
97}
98
99/// Decode bytes into a protobuf message.
100pub fn decode_proto<M: Message>(data: &[u8]) -> Result<M, ConnectError> {
101 M::decode_from_slice(data)
102 .map_err(|e| ConnectError::invalid_argument(format!("failed to decode proto: {e}")))
103}
104
105/// Message shared by the JSON codec entry points when the `json` feature is
106/// disabled.
107#[cfg(not(feature = "json"))]
108pub(crate) const JSON_FEATURE_DISABLED: &str =
109 "JSON codec not compiled in (connectrpc built without the `json` feature)";
110
111/// Encode a message to JSON format.
112///
113/// This (with [`decode_json`]) is the primary place the `json` feature is
114/// gated: with it disabled, the JSON codec is unavailable and this returns
115/// [`ErrorCode::Unimplemented`](crate::ErrorCode::Unimplemented) without
116/// requiring `M: serde::Serialize`, so proto-only callers compile. Callers can
117/// therefore invoke it unconditionally on their `CodecFormat::Json` arm. The
118/// one deliberate exception is the client's `decode_response_view`, which keeps
119/// its own `#[cfg]` gate so a failed response decode stays an `internal` error
120/// rather than `decode_json`'s `invalid_argument`.
121#[cfg(feature = "json")]
122pub fn encode_json<M: Serialize>(message: &M) -> Result<Bytes, ConnectError> {
123 serde_json::to_vec(message)
124 .map(Bytes::from)
125 .map_err(|e| ConnectError::internal(format!("failed to encode JSON: {e}")))
126}
127
128/// Encode a message to JSON format — proto-only build: always `Unimplemented`.
129#[cfg(not(feature = "json"))]
130pub fn encode_json<M>(_message: &M) -> Result<Bytes, ConnectError> {
131 Err(ConnectError::unimplemented(JSON_FEATURE_DISABLED))
132}
133
134/// Decode JSON bytes into a message.
135///
136/// See [`encode_json`]: with the `json` feature disabled this returns
137/// [`ErrorCode::Unimplemented`](crate::ErrorCode::Unimplemented) without
138/// requiring `M: serde::de::DeserializeOwned`.
139#[cfg(feature = "json")]
140pub fn decode_json<M: DeserializeOwned>(data: &[u8]) -> Result<M, ConnectError> {
141 serde_json::from_slice(data)
142 .map_err(|e| ConnectError::invalid_argument(format!("failed to decode JSON: {e}")))
143}
144
145/// Decode JSON bytes into a message — proto-only build: always `Unimplemented`.
146#[cfg(not(feature = "json"))]
147pub fn decode_json<M>(_data: &[u8]) -> Result<M, ConnectError> {
148 Err(ConnectError::unimplemented(JSON_FEATURE_DISABLED))
149}
150
151/// Codec for binary protobuf encoding.
152#[derive(Debug, Clone, Copy, Default)]
153pub struct ProtoCodec;
154
155impl ProtoCodec {
156 /// Get the content type for this codec.
157 pub fn content_type() -> &'static str {
158 content_type::PROTO
159 }
160
161 /// Encode a protobuf message to bytes.
162 pub fn encode<M: Message>(message: &M) -> Result<Bytes, ConnectError> {
163 encode_proto(message)
164 }
165
166 /// Decode bytes into a protobuf message.
167 pub fn decode<M: Message>(data: &[u8]) -> Result<M, ConnectError> {
168 decode_proto(data)
169 }
170}
171
172/// Codec for JSON encoding of protobuf messages.
173#[cfg(feature = "json")]
174#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
175#[derive(Debug, Clone, Copy, Default)]
176pub struct JsonCodec;
177
178#[cfg(feature = "json")]
179impl JsonCodec {
180 /// Get the content type for this codec.
181 pub fn content_type() -> &'static str {
182 content_type::JSON
183 }
184
185 /// Encode a message to JSON bytes.
186 pub fn encode<M: Serialize>(message: &M) -> Result<Bytes, ConnectError> {
187 encode_json(message)
188 }
189
190 /// Decode JSON bytes into a message.
191 pub fn decode<M: DeserializeOwned>(data: &[u8]) -> Result<M, ConnectError> {
192 decode_json(data)
193 }
194}
195
196/// Supported codec formats.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198#[non_exhaustive]
199pub enum CodecFormat {
200 /// Binary protobuf format.
201 Proto,
202 /// JSON format.
203 ///
204 /// Fully supported only when the `json` feature is enabled. The variant
205 /// always exists (the wire-protocol enums are codec-total), but with the
206 /// feature disabled a proto-only build rejects JSON at the edges: the
207 /// server declines JSON content types at negotiation
208 /// ([`from_content_type`](Self::from_content_type) /
209 /// [`from_codec`](Self::from_codec) return `None`), which yields HTTP 415
210 /// for Connect or a gRPC error status for gRPC/gRPC-Web; and message
211 /// encode/decode returns
212 /// [`ErrorCode::Unimplemented`](crate::ErrorCode::Unimplemented) as a
213 /// backstop. Connect *error* bodies are always JSON regardless.
214 Json,
215}
216
217impl std::fmt::Display for CodecFormat {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 Self::Proto => write!(f, "proto"),
221 Self::Json => write!(f, "json"),
222 }
223 }
224}
225
226impl CodecFormat {
227 /// Parse codec format from content type string.
228 ///
229 /// With the `json` feature disabled (a proto-only build) a JSON content
230 /// type returns `None` instead of [`CodecFormat::Json`], so the server
231 /// rejects it as an unsupported media type at content negotiation rather
232 /// than accepting it and failing later at decode. The message-level
233 /// encode/decode gating remains as a backstop.
234 pub fn from_content_type(content_type: &str) -> Option<Self> {
235 if content_type.starts_with(content_type::PROTO)
236 || content_type.starts_with(content_type::CONNECT_PROTO)
237 {
238 return Some(Self::Proto);
239 }
240 #[cfg(feature = "json")]
241 if content_type.starts_with(content_type::JSON)
242 || content_type.starts_with(content_type::CONNECT_JSON)
243 {
244 return Some(Self::Json);
245 }
246 None
247 }
248
249 /// Parse codec format from encoding name (used in GET request query params).
250 ///
251 /// Accepts `"proto"`, and `"json"` only when the `json` feature is enabled
252 /// (the values used in the `encoding` query parameter). In a proto-only
253 /// build `"json"` returns `None`, so a Connect GET requesting the JSON
254 /// codec is rejected as an unsupported media type.
255 pub fn from_codec(codec: &str) -> Option<Self> {
256 match codec {
257 "proto" => Some(Self::Proto),
258 #[cfg(feature = "json")]
259 "json" => Some(Self::Json),
260 _ => None,
261 }
262 }
263
264 /// Get the content type string for this format (unary RPC).
265 #[inline]
266 pub fn content_type(&self) -> &'static str {
267 match self {
268 Self::Proto => content_type::PROTO,
269 Self::Json => content_type::JSON,
270 }
271 }
272
273 /// Get the streaming content type string for this format.
274 #[inline]
275 pub fn streaming_content_type(&self) -> &'static str {
276 match self {
277 Self::Proto => content_type::CONNECT_PROTO,
278 Self::Json => content_type::CONNECT_JSON,
279 }
280 }
281
282 /// Check if the given content type indicates a streaming request.
283 ///
284 /// With the `json` feature disabled, the `application/connect+json`
285 /// streaming content type is not recognized (a proto-only build treats it
286 /// as an unsupported media type), matching [`Self::from_content_type`].
287 #[inline]
288 pub fn is_streaming_content_type(content_type: &str) -> bool {
289 if content_type.starts_with(content_type::CONNECT_PROTO) {
290 return true;
291 }
292 #[cfg(feature = "json")]
293 if content_type.starts_with(content_type::CONNECT_JSON) {
294 return true;
295 }
296 false
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 /// With the `json` feature disabled, the message-type markers must be
303 /// empty bounds: a type with no serde derives — as emitted by the codegen
304 /// `no_json` option — still satisfies them. This is exactly what lets
305 /// proto-only generated code compile against this crate. (When `json` is
306 /// enabled the markers are `serde::Serialize` / `DeserializeOwned`, so the
307 /// assertion below would not even build — hence the `cfg`.)
308 #[cfg(not(feature = "json"))]
309 #[test]
310 fn markers_are_empty_bounds_without_json() {
311 use super::{JsonDeserialize, JsonSerialize};
312
313 // Derives neither `Serialize` nor `Deserialize`.
314 struct NoSerde;
315
316 fn assert_serialize<T: JsonSerialize>() {}
317 fn assert_deserialize<T: JsonDeserialize>() {}
318
319 assert_serialize::<NoSerde>();
320 assert_deserialize::<NoSerde>();
321 }
322
323 /// Proto-only build: the codec parsers decline every JSON content type and
324 /// the `json` GET encoding, so the server rejects them at negotiation.
325 #[cfg(not(feature = "json"))]
326 #[test]
327 fn parsers_reject_json_without_feature() {
328 use super::CodecFormat;
329
330 assert_eq!(CodecFormat::from_codec("json"), None);
331 assert_eq!(CodecFormat::from_codec("proto"), Some(CodecFormat::Proto));
332
333 for ct in ["application/json", "application/connect+json"] {
334 assert_eq!(CodecFormat::from_content_type(ct), None, "{ct}");
335 }
336 assert_eq!(
337 CodecFormat::from_content_type("application/proto"),
338 Some(CodecFormat::Proto)
339 );
340
341 assert!(!CodecFormat::is_streaming_content_type(
342 "application/connect+json"
343 ));
344 assert!(CodecFormat::is_streaming_content_type(
345 "application/connect+proto"
346 ));
347 }
348
349 #[cfg(feature = "json")]
350 #[test]
351 fn parsers_accept_json_with_feature() {
352 use super::CodecFormat;
353
354 assert_eq!(CodecFormat::from_codec("json"), Some(CodecFormat::Json));
355 assert_eq!(
356 CodecFormat::from_content_type("application/json"),
357 Some(CodecFormat::Json)
358 );
359 assert!(CodecFormat::is_streaming_content_type(
360 "application/connect+json"
361 ));
362 }
363}