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 under buffa's default limits.
100pub fn decode_proto<M: Message>(data: &[u8]) -> Result<M, ConnectError> {
101 decode_proto_with_options(data, &buffa::DecodeOptions::new())
102}
103
104/// Decode bytes into a protobuf message under explicit decode limits.
105///
106/// The server passes the limits from its [`Limits`](crate::Limits); see
107/// [`Payload::decode_options`](crate::Payload::decode_options) for how they
108/// reach an owned-message handler.
109pub fn decode_proto_with_options<M: Message>(
110 data: &[u8],
111 options: &buffa::DecodeOptions,
112) -> Result<M, ConnectError> {
113 options
114 .decode_from_slice(data)
115 .map_err(|e| ConnectError::invalid_argument(format!("failed to decode proto: {e}")))
116}
117
118/// Message shared by the JSON codec entry points when the `json` feature is
119/// disabled.
120#[cfg(not(feature = "json"))]
121pub(crate) const JSON_FEATURE_DISABLED: &str =
122 "JSON codec not compiled in (connectrpc built without the `json` feature)";
123
124/// Encode a message to JSON format.
125///
126/// This (with [`decode_json`]) is the primary place the `json` feature is
127/// gated: with it disabled, the JSON codec is unavailable and this returns
128/// [`ErrorCode::Unimplemented`](crate::ErrorCode::Unimplemented) without
129/// requiring `M: serde::Serialize`, so proto-only callers compile. Callers can
130/// therefore invoke it unconditionally on their `CodecFormat::Json` arm. The
131/// one deliberate exception is the client's `decode_response_view`, which keeps
132/// its own `#[cfg]` gate so a failed response decode stays an `internal` error
133/// rather than `decode_json`'s `invalid_argument`.
134#[cfg(feature = "json")]
135pub fn encode_json<M: Serialize>(message: &M) -> Result<Bytes, ConnectError> {
136 serde_json::to_vec(message)
137 .map(Bytes::from)
138 .map_err(|e| ConnectError::internal(format!("failed to encode JSON: {e}")))
139}
140
141/// Encode a message to JSON format — proto-only build: always `Unimplemented`.
142#[cfg(not(feature = "json"))]
143pub fn encode_json<M>(_message: &M) -> Result<Bytes, ConnectError> {
144 Err(ConnectError::unimplemented(JSON_FEATURE_DISABLED))
145}
146
147/// Decode JSON bytes into a message.
148///
149/// See [`encode_json`]: with the `json` feature disabled this returns
150/// [`ErrorCode::Unimplemented`](crate::ErrorCode::Unimplemented) without
151/// requiring `M: serde::de::DeserializeOwned`.
152#[cfg(feature = "json")]
153pub fn decode_json<M: DeserializeOwned>(data: &[u8]) -> Result<M, ConnectError> {
154 serde_json::from_slice(data)
155 .map_err(|e| ConnectError::invalid_argument(format!("failed to decode JSON: {e}")))
156}
157
158/// Decode JSON bytes into a message — proto-only build: always `Unimplemented`.
159#[cfg(not(feature = "json"))]
160pub fn decode_json<M>(_data: &[u8]) -> Result<M, ConnectError> {
161 Err(ConnectError::unimplemented(JSON_FEATURE_DISABLED))
162}
163
164/// Codec for binary protobuf encoding.
165#[derive(Debug, Clone, Copy, Default)]
166pub struct ProtoCodec;
167
168impl ProtoCodec {
169 /// Get the content type for this codec.
170 pub fn content_type() -> &'static str {
171 content_type::PROTO
172 }
173
174 /// Encode a protobuf message to bytes.
175 pub fn encode<M: Message>(message: &M) -> Result<Bytes, ConnectError> {
176 encode_proto(message)
177 }
178
179 /// Decode bytes into a protobuf message.
180 pub fn decode<M: Message>(data: &[u8]) -> Result<M, ConnectError> {
181 decode_proto(data)
182 }
183}
184
185/// Codec for JSON encoding of protobuf messages.
186#[cfg(feature = "json")]
187#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
188#[derive(Debug, Clone, Copy, Default)]
189pub struct JsonCodec;
190
191#[cfg(feature = "json")]
192impl JsonCodec {
193 /// Get the content type for this codec.
194 pub fn content_type() -> &'static str {
195 content_type::JSON
196 }
197
198 /// Encode a message to JSON bytes.
199 pub fn encode<M: Serialize>(message: &M) -> Result<Bytes, ConnectError> {
200 encode_json(message)
201 }
202
203 /// Decode JSON bytes into a message.
204 pub fn decode<M: DeserializeOwned>(data: &[u8]) -> Result<M, ConnectError> {
205 decode_json(data)
206 }
207}
208
209/// Supported codec formats.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211#[non_exhaustive]
212pub enum CodecFormat {
213 /// Binary protobuf format.
214 Proto,
215 /// JSON format.
216 ///
217 /// Fully supported only when the `json` feature is enabled. The variant
218 /// always exists (the wire-protocol enums are codec-total), but with the
219 /// feature disabled a proto-only build rejects JSON at the edges: the
220 /// server declines JSON content types at negotiation
221 /// ([`from_content_type`](Self::from_content_type) /
222 /// [`from_codec`](Self::from_codec) return `None`), which yields HTTP 415
223 /// for Connect or a gRPC error status for gRPC/gRPC-Web; and message
224 /// encode/decode returns
225 /// [`ErrorCode::Unimplemented`](crate::ErrorCode::Unimplemented) as a
226 /// backstop. Connect *error* bodies are always JSON regardless.
227 Json,
228}
229
230impl std::fmt::Display for CodecFormat {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 match self {
233 Self::Proto => write!(f, "proto"),
234 Self::Json => write!(f, "json"),
235 }
236 }
237}
238
239impl CodecFormat {
240 /// Parse codec format from content type string.
241 ///
242 /// With the `json` feature disabled (a proto-only build) a JSON content
243 /// type returns `None` instead of [`CodecFormat::Json`], so the server
244 /// rejects it as an unsupported media type at content negotiation rather
245 /// than accepting it and failing later at decode. The message-level
246 /// encode/decode gating remains as a backstop.
247 pub fn from_content_type(content_type: &str) -> Option<Self> {
248 if content_type.starts_with(content_type::PROTO)
249 || content_type.starts_with(content_type::CONNECT_PROTO)
250 {
251 return Some(Self::Proto);
252 }
253 #[cfg(feature = "json")]
254 if content_type.starts_with(content_type::JSON)
255 || content_type.starts_with(content_type::CONNECT_JSON)
256 {
257 return Some(Self::Json);
258 }
259 None
260 }
261
262 /// Parse codec format from encoding name (used in GET request query params).
263 ///
264 /// Accepts `"proto"`, and `"json"` only when the `json` feature is enabled
265 /// (the values used in the `encoding` query parameter). In a proto-only
266 /// build `"json"` returns `None`, so a Connect GET requesting the JSON
267 /// codec is rejected as an unsupported media type.
268 pub fn from_codec(codec: &str) -> Option<Self> {
269 match codec {
270 "proto" => Some(Self::Proto),
271 #[cfg(feature = "json")]
272 "json" => Some(Self::Json),
273 _ => None,
274 }
275 }
276
277 /// Get the content type string for this format (unary RPC).
278 #[inline]
279 pub fn content_type(&self) -> &'static str {
280 match self {
281 Self::Proto => content_type::PROTO,
282 Self::Json => content_type::JSON,
283 }
284 }
285
286 /// Get the streaming content type string for this format.
287 #[inline]
288 pub fn streaming_content_type(&self) -> &'static str {
289 match self {
290 Self::Proto => content_type::CONNECT_PROTO,
291 Self::Json => content_type::CONNECT_JSON,
292 }
293 }
294
295 /// Check if the given content type indicates a streaming request.
296 ///
297 /// With the `json` feature disabled, the `application/connect+json`
298 /// streaming content type is not recognized (a proto-only build treats it
299 /// as an unsupported media type), matching [`Self::from_content_type`].
300 #[inline]
301 pub fn is_streaming_content_type(content_type: &str) -> bool {
302 if content_type.starts_with(content_type::CONNECT_PROTO) {
303 return true;
304 }
305 #[cfg(feature = "json")]
306 if content_type.starts_with(content_type::CONNECT_JSON) {
307 return true;
308 }
309 false
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 /// With the `json` feature disabled, the message-type markers must be
316 /// empty bounds: a type with no serde derives — as emitted by the codegen
317 /// `no_json` option — still satisfies them. This is exactly what lets
318 /// proto-only generated code compile against this crate. (When `json` is
319 /// enabled the markers are `serde::Serialize` / `DeserializeOwned`, so the
320 /// assertion below would not even build — hence the `cfg`.)
321 #[cfg(not(feature = "json"))]
322 #[test]
323 fn markers_are_empty_bounds_without_json() {
324 use super::{JsonDeserialize, JsonSerialize};
325
326 // Derives neither `Serialize` nor `Deserialize`.
327 struct NoSerde;
328
329 fn assert_serialize<T: JsonSerialize>() {}
330 fn assert_deserialize<T: JsonDeserialize>() {}
331
332 assert_serialize::<NoSerde>();
333 assert_deserialize::<NoSerde>();
334 }
335
336 /// Proto-only build: the codec parsers decline every JSON content type and
337 /// the `json` GET encoding, so the server rejects them at negotiation.
338 #[cfg(not(feature = "json"))]
339 #[test]
340 fn parsers_reject_json_without_feature() {
341 use super::CodecFormat;
342
343 assert_eq!(CodecFormat::from_codec("json"), None);
344 assert_eq!(CodecFormat::from_codec("proto"), Some(CodecFormat::Proto));
345
346 for ct in ["application/json", "application/connect+json"] {
347 assert_eq!(CodecFormat::from_content_type(ct), None, "{ct}");
348 }
349 assert_eq!(
350 CodecFormat::from_content_type("application/proto"),
351 Some(CodecFormat::Proto)
352 );
353
354 assert!(!CodecFormat::is_streaming_content_type(
355 "application/connect+json"
356 ));
357 assert!(CodecFormat::is_streaming_content_type(
358 "application/connect+proto"
359 ));
360 }
361
362 #[cfg(feature = "json")]
363 #[test]
364 fn parsers_accept_json_with_feature() {
365 use super::CodecFormat;
366
367 assert_eq!(CodecFormat::from_codec("json"), Some(CodecFormat::Json));
368 assert_eq!(
369 CodecFormat::from_content_type("application/json"),
370 Some(CodecFormat::Json)
371 );
372 assert!(CodecFormat::is_streaming_content_type(
373 "application/connect+json"
374 ));
375 }
376}