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 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}