Skip to main content

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}